authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 19:29:22-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log85d5df625652906937d5ac330205b1042dc9354f
tree8db1b0aa166b6edbcdb2650d0686c6e7c425a305
parentb5b769dddfe935650b48473dd5b55f2c099abdd8

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

35 files changed, 1779 insertions(+), 328 deletions(-)

apps/server/src/assets/AssetAccess.test.ts created+150
...@@ -0,0 +1,150 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerConfig } from "../config.ts";
11import { ProjectFaviconResolverLive } from "../project/Layers/ProjectFaviconResolver.ts";
12import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts";
13import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";
14
15const configLayer = ServerConfig.layerTest(process.cwd(), {
16 prefix: "t3-asset-access-test-",
17});
18const testLayer = Layer.mergeAll(
19 configLayer,
20 WorkspacePathsLive,
21 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
22 ServerSecretStore.layer.pipe(Layer.provide(configLayer)),
23).pipe(Layer.provideMerge(NodeServices.layer));
24
25describe("AssetAccess", () => {
26 it.effect("issues workspace URLs that resolve the entry file and sibling assets", () =>
27 Effect.gen(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
30 const root = yield* fileSystem.makeTempDirectoryScoped({
31 prefix: "t3-asset-workspace-",
32 });
33 const htmlPath = path.join(root, "report.html");
34 const cssPath = path.join(root, "report.css");
35 yield* fileSystem.writeFileString(htmlPath, '<link rel="stylesheet" href="report.css">');
36 yield* fileSystem.writeFileString(cssPath, "body { color: red; }");
37 yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value");
38
39 const result = yield* issueAssetUrl({
40 resource: {
41 _tag: "workspace-file",
42 threadId: ThreadId.make("thread-1"),
43 path: htmlPath,
44 },
45 workspaceRoot: root,
46 });
47 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
48 const separatorIndex = suffix.indexOf("/");
49 const token = suffix.slice(0, separatorIndex);
50
51 expect(yield* resolveAsset(token, "report.html")).toEqual({
52 kind: "file",
53 path: htmlPath,
54 });
55 expect(yield* resolveAsset(token, "report.css")).toEqual({
56 kind: "file",
57 path: cssPath,
58 });
59 expect(yield* resolveAsset(token, "../secret.txt")).toBeNull();
60 expect(yield* resolveAsset(token, ".env")).toBeNull();
61 expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull();
62 }).pipe(Effect.provide(testLayer)),
63 );
64
65 it.effect("rejects workspace files outside the authorized root", () =>
66 Effect.gen(function* () {
67 const fileSystem = yield* FileSystem.FileSystem;
68 const path = yield* Path.Path;
69 const root = yield* fileSystem.makeTempDirectoryScoped({
70 prefix: "t3-asset-root-",
71 });
72 const outside = yield* fileSystem.makeTempDirectoryScoped({
73 prefix: "t3-asset-outside-",
74 });
75 const htmlPath = path.join(outside, "report.html");
76 yield* fileSystem.writeFileString(htmlPath, "<p>outside</p>");
77
78 const error = yield* issueAssetUrl({
79 resource: {
80 _tag: "workspace-file",
81 threadId: ThreadId.make("thread-1"),
82 path: htmlPath,
83 },
84 workspaceRoot: root,
85 }).pipe(Effect.flip);
86 expect(error.message).toContain("relative to the project root");
87 }).pipe(Effect.provide(testLayer)),
88 );
89
90 it.effect("issues exact attachment capabilities by attachment id", () =>
91 Effect.gen(function* () {
92 const config = yield* ServerConfig;
93 const fileSystem = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001";
96 const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.png`);
97 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
98 yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
99
100 const result = yield* issueAssetUrl({
101 resource: { _tag: "attachment", attachmentId },
102 });
103 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
104 const separatorIndex = suffix.indexOf("/");
105 const token = suffix.slice(0, separatorIndex);
106
107 expect(yield* resolveAsset(token, "ignored.png")).toEqual({
108 kind: "file",
109 path: attachmentPath,
110 });
111 }).pipe(Effect.provide(testLayer)),
112 );
113
114 it.effect("issues project favicon capabilities with a signed fallback", () =>
115 Effect.gen(function* () {
116 const fileSystem = yield* FileSystem.FileSystem;
117 const path = yield* Path.Path;
118 const root = yield* fileSystem.makeTempDirectoryScoped({
119 prefix: "t3-asset-favicon-",
120 });
121 const faviconPath = path.join(root, "favicon.svg");
122 yield* fileSystem.writeFileString(faviconPath, "<svg />");
123
124 const faviconResult = yield* issueAssetUrl({
125 resource: { _tag: "project-favicon", cwd: root },
126 });
127 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
128 const faviconSeparatorIndex = faviconSuffix.indexOf("/");
129 expect(
130 yield* resolveAsset(
131 faviconSuffix.slice(0, faviconSeparatorIndex),
132 faviconSuffix.slice(faviconSeparatorIndex + 1),
133 ),
134 ).toEqual({ kind: "file", path: faviconPath });
135
136 yield* fileSystem.remove(faviconPath);
137 const fallbackResult = yield* issueAssetUrl({
138 resource: { _tag: "project-favicon", cwd: root },
139 });
140 const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
141 const fallbackSeparatorIndex = fallbackSuffix.indexOf("/");
142 expect(
143 yield* resolveAsset(
144 fallbackSuffix.slice(0, fallbackSeparatorIndex),
145 fallbackSuffix.slice(fallbackSeparatorIndex + 1),
146 ),
147 ).toEqual({ kind: "project-favicon-fallback" });
148 }).pipe(Effect.provide(testLayer)),
149 );
150});
apps/server/src/assets/AssetAccess.ts created+287
...@@ -0,0 +1,287 @@
1import type { AssetResource } from "@t3tools/contracts";
2import { AssetAccessError } from "@t3tools/contracts";
3import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9
10import {
11 base64UrlDecodeUtf8,
12 base64UrlEncode,
13 signPayload,
14 timingSafeEqualBase64Url,
15} from "../auth/utils.ts";
16import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
17import { resolveAttachmentPathById } from "../attachmentStore.ts";
18import { ServerConfig } from "../config.ts";
19import { ProjectFaviconResolver } from "../project/Services/ProjectFaviconResolver.ts";
20import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts";
21
22export const ASSET_ROUTE_PREFIX = "/api/assets";
23export const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
24
25const SIGNING_SECRET_NAME = "asset-access-signing-key";
26const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
27const PREVIEWABLE_EXTENSIONS = new Set([".htm", ".html", ".pdf"]);
28const PREVIEW_ASSET_EXTENSIONS = new Set([
29 ...PREVIEWABLE_EXTENSIONS,
30 ".avif",
31 ".css",
32 ".gif",
33 ".ico",
34 ".jpeg",
35 ".jpg",
36 ".js",
37 ".mjs",
38 ".otf",
39 ".png",
40 ".svg",
41 ".ttf",
42 ".webp",
43 ".woff",
44 ".woff2",
45]);
46
47const AssetClaimsSchema = Schema.Union([
48 Schema.Struct({
49 version: Schema.Literal(1),
50 kind: Schema.Literal("workspace-file"),
51 workspaceRoot: Schema.String,
52 baseRelativePath: Schema.String,
53 expiresAt: Schema.Number,
54 }),
55 Schema.Struct({
56 version: Schema.Literal(1),
57 kind: Schema.Literal("attachment"),
58 attachmentId: Schema.String,
59 expiresAt: Schema.Number,
60 }),
61 Schema.Struct({
62 version: Schema.Literal(1),
63 kind: Schema.Literal("project-favicon"),
64 workspaceRoot: Schema.String,
65 relativePath: Schema.NullOr(Schema.String),
66 expiresAt: Schema.Number,
67 }),
68]);
69type AssetClaims = typeof AssetClaimsSchema.Type;
70
71const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
72const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
73const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
74
75export type ResolvedAsset =
76 | { readonly kind: "file"; readonly path: string }
77 | { readonly kind: "project-favicon-fallback" };
78
79function decodeClaims(encodedPayload: string): AssetClaims | null {
80 try {
81 return Option.getOrNull(decodeAssetClaims(base64UrlDecodeUtf8(encodedPayload)));
82 } catch {
83 return null;
84 }
85}
86
87function decodeRelativePath(value: string): string | null {
88 try {
89 return decodeURIComponent(value);
90 } catch {
91 return null;
92 }
93}
94
95const failAccess = (message: string, cause?: unknown) =>
96 new AssetAccessError({ message, ...(cause === undefined ? {} : { cause }) });
97
98const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")(
99 function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) {
100 const fileSystem = yield* FileSystem.FileSystem;
101 const workspacePaths = yield* WorkspacePaths;
102 const resolved = yield* workspacePaths
103 .resolveRelativePathWithinRoot(input)
104 .pipe(Effect.orElseSucceed(() => null));
105 if (!resolved) return null;
106
107 const [canonicalRoot, canonicalFile] = yield* Effect.all([
108 fileSystem.realPath(input.workspaceRoot).pipe(Effect.orElseSucceed(() => null)),
109 fileSystem.realPath(resolved.absolutePath).pipe(Effect.orElseSucceed(() => null)),
110 ]);
111 if (!canonicalRoot || !canonicalFile) return null;
112
113 const path = yield* Path.Path;
114 const relative = path.relative(canonicalRoot, canonicalFile);
115 if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return null;
116
117 const info = yield* fileSystem.stat(canonicalFile).pipe(Effect.orElseSucceed(() => null));
118 return info?.type === "File" ? canonicalFile : null;
119 },
120);
121
122export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: {
123 readonly resource: AssetResource;
124 readonly workspaceRoot?: string;
125}) {
126 const fileSystem = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const workspacePaths = yield* WorkspacePaths;
129 const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
130 let claims: AssetClaims;
131 let fileName: string;
132
133 switch (input.resource._tag) {
134 case "workspace-file": {
135 if (!input.workspaceRoot) {
136 return yield* failAccess("Workspace context was not found.");
137 }
138 const workspaceRoot = yield* workspacePaths
139 .normalizeWorkspaceRoot(input.workspaceRoot)
140 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
141 const relativePath = path.isAbsolute(input.resource.path)
142 ? path.relative(workspaceRoot, input.resource.path)
143 : input.resource.path;
144 const resolved = yield* workspacePaths
145 .resolveRelativePathWithinRoot({ workspaceRoot, relativePath })
146 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
147 if (!PREVIEWABLE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) {
148 return yield* failAccess("Only HTML and PDF files can open in the browser.");
149 }
150 const canonicalFile = yield* resolveCanonicalWorkspaceFile({
151 workspaceRoot,
152 relativePath: resolved.relativePath,
153 });
154 if (!canonicalFile) {
155 return yield* failAccess("Workspace asset was not found.");
156 }
157 claims = {
158 version: 1,
159 kind: "workspace-file",
160 workspaceRoot: yield* fileSystem
161 .realPath(workspaceRoot)
162 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
163 baseRelativePath: path.dirname(resolved.relativePath),
164 expiresAt,
165 };
166 fileName = path.basename(resolved.relativePath);
167 break;
168 }
169 case "attachment": {
170 const config = yield* ServerConfig;
171 const attachmentPath = resolveAttachmentPathById({
172 attachmentsDir: config.attachmentsDir,
173 attachmentId: input.resource.attachmentId,
174 });
175 if (!attachmentPath) {
176 return yield* failAccess("Attachment was not found.");
177 }
178 claims = {
179 version: 1,
180 kind: "attachment",
181 attachmentId: input.resource.attachmentId,
182 expiresAt,
183 };
184 fileName = path.basename(attachmentPath);
185 break;
186 }
187 case "project-favicon": {
188 const workspaceRoot = yield* workspacePaths
189 .normalizeWorkspaceRoot(input.resource.cwd)
190 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
191 const faviconResolver = yield* ProjectFaviconResolver;
192 const faviconPath = yield* faviconResolver.resolvePath(workspaceRoot);
193 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
194 if (
195 relativePath &&
196 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }))
197 ) {
198 return yield* failAccess("Project favicon was not found.");
199 }
200 claims = {
201 version: 1,
202 kind: "project-favicon",
203 workspaceRoot: yield* fileSystem
204 .realPath(workspaceRoot)
205 .pipe(Effect.mapError((cause) => failAccess("Failed to resolve workspace.", cause))),
206 relativePath,
207 expiresAt,
208 };
209 fileName = relativePath ? path.basename(relativePath) : "favicon.svg";
210 break;
211 }
212 }
213
214 const secretStore = yield* ServerSecretStore;
215 const signingSecret = yield* secretStore
216 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
217 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
218 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
219 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
220 return {
221 relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`,
222 expiresAt,
223 };
224});
225
226export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
227 token: string,
228 relativePath: string,
229) {
230 const [encodedPayload, signature] = token.split(".");
231 if (!encodedPayload || !signature) return null;
232
233 const secretStore = yield* ServerSecretStore;
234 const signingSecret = yield* secretStore
235 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
236 .pipe(Effect.orElseSucceed(() => null));
237 if (!signingSecret) return null;
238 if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null;
239
240 const claims = decodeClaims(encodedPayload);
241 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
242
243 if (claims.kind === "attachment") {
244 const config = yield* ServerConfig;
245 const attachmentPath = resolveAttachmentPathById({
246 attachmentsDir: config.attachmentsDir,
247 attachmentId: claims.attachmentId,
248 });
249 if (!attachmentPath) return null;
250 const fileSystem = yield* FileSystem.FileSystem;
251 const info = yield* fileSystem.stat(attachmentPath).pipe(Effect.orElseSucceed(() => null));
252 return info?.type === "File"
253 ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
254 : null;
255 }
256
257 if (claims.kind === "project-favicon") {
258 if (claims.relativePath === null) {
259 return { kind: "project-favicon-fallback" } satisfies ResolvedAsset;
260 }
261 const faviconPath = yield* resolveCanonicalWorkspaceFile({
262 workspaceRoot: claims.workspaceRoot,
263 relativePath: claims.relativePath,
264 });
265 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
266 }
267
268 const decodedPath = decodeRelativePath(relativePath);
269 if (decodedPath === null) return null;
270 const path = yield* Path.Path;
271 const segments = decodedPath.split(/[\\/]/);
272 if (
273 decodedPath.length === 0 ||
274 decodedPath.includes("\0") ||
275 segments.some((segment) => segment === "." || segment === ".." || segment.startsWith(".")) ||
276 !PREVIEW_ASSET_EXTENSIONS.has(path.extname(decodedPath).toLowerCase())
277 ) {
278 return null;
279 }
280 const joinedRelativePath =
281 claims.baseRelativePath === "." ? decodedPath : path.join(claims.baseRelativePath, decodedPath);
282 const workspaceFile = yield* resolveCanonicalWorkspaceFile({
283 workspaceRoot: claims.workspaceRoot,
284 relativePath: joinedRelativePath,
285 });
286 return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null;
287});
apps/server/src/attachmentPaths.ts-2
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import NodePath from "node:path";2import NodePath from "node:path";
33
4export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
5
6export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {4export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
7 const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, "");5 const normalized = NodePath.normalize(rawRelativePath).replace(/^[/\\]+/, "");
8 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {6 if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) {
apps/server/src/http.ts+22-83
...@@ -24,15 +24,13 @@ import {...@@ -24,15 +24,13 @@ import {
24import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";24import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
25import { OtlpTracer } from "effect/unstable/observability";25import { OtlpTracer } from "effect/unstable/observability";
2626
27import {
28 ATTACHMENTS_ROUTE_PREFIX,
29 normalizeAttachmentRelativePath,
30 resolveAttachmentRelativePath,
31} from "./attachmentPaths.ts";
32import { resolveAttachmentPathById } from "./attachmentStore.ts";
33import { resolveStaticDir, ServerConfig } from "./config.ts";27import { resolveStaticDir, ServerConfig } from "./config.ts";
28import {
29 ASSET_ROUTE_PREFIX,
30 FALLBACK_PROJECT_FAVICON_SVG,
31 resolveAsset,
32} from "./assets/AssetAccess.ts";
34import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
35import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
36import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";34import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
37import {35import {
38 annotateEnvironmentRequest,36 annotateEnvironmentRequest,
...@@ -43,8 +41,6 @@ import {...@@ -43,8 +41,6 @@ import {
43import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";41import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
44import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";42import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4543
46const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
47const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
48const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";44const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
49const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);45const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
5046
...@@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(...@@ -169,107 +165,50 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
169 ),165 ),
170);166);
171167
172export const attachmentsRouteLayer = HttpRouter.add(168export const assetRouteLayer = HttpRouter.add(
173 "GET",169 "GET",
174 `${ATTACHMENTS_ROUTE_PREFIX}/*`,170 `${ASSET_ROUTE_PREFIX}/*`,
175 Effect.gen(function* () {171 Effect.gen(function* () {
176 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
177 const request = yield* HttpServerRequest.HttpServerRequest;172 const request = yield* HttpServerRequest.HttpServerRequest;
178 const url = HttpServerRequest.toURL(request);173 const url = HttpServerRequest.toURL(request);
179 if (Option.isNone(url)) {174 if (Option.isNone(url)) {
180 return HttpServerResponse.text("Bad Request", { status: 400 });175 return HttpServerResponse.text("Bad Request", { status: 400 });
181 }176 }
182177
183 const config = yield* ServerConfig;178 const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
184 const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);179 const separatorIndex = suffix.indexOf("/");
185 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);180 if (separatorIndex <= 0) {
186 if (!normalizedRelativePath) {
187 return HttpServerResponse.text("Invalid attachment path", { status: 400 });
188 }
189
190 const isIdLookup =
191 !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
192 const filePath = isIdLookup
193 ? resolveAttachmentPathById({
194 attachmentsDir: config.attachmentsDir,
195 attachmentId: normalizedRelativePath,
196 })
197 : resolveAttachmentRelativePath({
198 attachmentsDir: config.attachmentsDir,
199 relativePath: normalizedRelativePath,
200 });
201 if (!filePath) {
202 return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
203 status: isIdLookup ? 404 : 400,
204 });
205 }
206
207 const fileSystem = yield* FileSystem.FileSystem;
208 const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
209 if (!fileInfo || fileInfo.type !== "File") {
210 return HttpServerResponse.text("Not Found", { status: 404 });181 return HttpServerResponse.text("Not Found", { status: 404 });
211 }182 }
212183
213 return yield* HttpServerResponse.file(filePath, {184 const asset = yield* resolveAsset(
214 status: 200,185 suffix.slice(0, separatorIndex),
215 headers: {186 suffix.slice(separatorIndex + 1),
216 "Cache-Control": "public, max-age=31536000, immutable",
217 },
218 }).pipe(
219 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
220 );187 );
221 }).pipe(188 if (!asset) {
222 Effect.catchTags({189 return HttpServerResponse.text("Not Found", { status: 404 });
223 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
224 EnvironmentInternalError: HttpServerRespondable.toResponse,
225 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
226 }),
227 ),
228);
229
230export const projectFaviconRouteLayer = HttpRouter.add(
231 "GET",
232 "/api/project-favicon",
233 Effect.gen(function* () {
234 yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope);
235 const request = yield* HttpServerRequest.HttpServerRequest;
236 const url = HttpServerRequest.toURL(request);
237 if (Option.isNone(url)) {
238 return HttpServerResponse.text("Bad Request", { status: 400 });
239 }
240
241 const projectCwd = url.value.searchParams.get("cwd");
242 if (!projectCwd) {
243 return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
244 }190 }
245191 if (asset.kind === "project-favicon-fallback") {
246 const faviconResolver = yield* ProjectFaviconResolver;
247 const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
248 if (!faviconFilePath) {
249 return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {192 return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
250 status: 200,193 status: 200,
251 contentType: "image/svg+xml",194 contentType: "image/svg+xml",
252 headers: {195 headers: {
253 "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,196 "Cache-Control": "private, max-age=3600",
197 "X-Content-Type-Options": "nosniff",
254 },198 },
255 });199 });
256 }200 }
257201
258 return yield* HttpServerResponse.file(faviconFilePath, {202 return yield* HttpServerResponse.file(asset.path, {
259 status: 200,203 status: 200,
260 headers: {204 headers: {
261 "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,205 "Cache-Control": "private, max-age=3600",
206 "X-Content-Type-Options": "nosniff",
262 },207 },
263 }).pipe(208 }).pipe(
264 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),209 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
265 );210 );
266 }).pipe(211 }),
267 Effect.catchTags({
268 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
269 EnvironmentInternalError: HttpServerRespondable.toResponse,
270 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
271 }),
272 ),
273);212);
274213
275export const staticAndDevRouteLayer = HttpRouter.add(214export const staticAndDevRouteLayer = HttpRouter.add(
apps/server/src/project/Layers/ProjectFaviconResolver.test.ts+2-1
...@@ -7,9 +7,10 @@ import * as Path from "effect/Path";...@@ -7,9 +7,10 @@ import * as Path from "effect/Path";
77
8import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";8import { ProjectFaviconResolver } from "../Services/ProjectFaviconResolver.ts";
9import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";9import { ProjectFaviconResolverLive } from "./ProjectFaviconResolver.ts";
10import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
1011
11const TestLayer = Layer.empty.pipe(12const TestLayer = Layer.empty.pipe(
12 Layer.provideMerge(ProjectFaviconResolverLive),13 Layer.provideMerge(ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive))),
13 Layer.provideMerge(NodeServices.layer),14 Layer.provideMerge(NodeServices.layer),
14);15);
1516
apps/server/src/project/Layers/ProjectFaviconResolver.ts+35-17
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 ProjectFaviconResolver,7 ProjectFaviconResolver,
8 type ProjectFaviconResolverShape,8 type ProjectFaviconResolverShape,
9} from "../Services/ProjectFaviconResolver.ts";9} from "../Services/ProjectFaviconResolver.ts";
10import { WorkspacePaths } from "../../workspace/Services/WorkspacePaths.ts";
1011
11// Well-known favicon paths checked in order.12// Well-known favicon paths checked in order.
12const FAVICON_CANDIDATES = [13const FAVICON_CANDIDATES = [
...@@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null {...@@ -61,28 +62,32 @@ function extractIconHref(source: string): string | null {
61export const makeProjectFaviconResolver = Effect.gen(function* () {62export const makeProjectFaviconResolver = Effect.gen(function* () {
62 const fileSystem = yield* FileSystem.FileSystem;63 const fileSystem = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;64 const path = yield* Path.Path;
65 const workspacePaths = yield* WorkspacePaths;
6466
65 const resolveIconHref = (projectCwd: string, href: string): string[] => {67 const resolveIconHref = (href: string): string[] => {
66 const clean = href.replace(/^\//, "");68 const clean = href.replace(/^\//, "");
67 return [path.join(projectCwd, "public", clean), path.join(projectCwd, clean)];69 return [path.join("public", clean), clean];
68 };
69
70 const isPathWithinProject = (projectCwd: string, candidatePath: string): boolean => {
71 const relative = path.relative(path.resolve(projectCwd), path.resolve(candidatePath));
72 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
73 };70 };
7471
75 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (72 const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* (
76 projectCwd: string,73 projectCwd: string,
77 candidates: ReadonlyArray<string>,74 relativeCandidates: ReadonlyArray<string>,
78 ): Effect.fn.Return<string | null> {75 ): Effect.fn.Return<string | null> {
79 for (const candidate of candidates) {76 for (const relativePath of relativeCandidates) {
80 if (!isPathWithinProject(projectCwd, candidate)) {77 const candidate = yield* workspacePaths
78 .resolveRelativePathWithinRoot({
79 workspaceRoot: projectCwd,
80 relativePath,
81 })
82 .pipe(Effect.orElseSucceed(() => null));
83 if (!candidate) {
81 continue;84 continue;
82 }85 }
83 const stats = yield* fileSystem.stat(candidate).pipe(Effect.orElseSucceed(() => null));86 const stats = yield* fileSystem
87 .stat(candidate.absolutePath)
88 .pipe(Effect.orElseSucceed(() => null));
84 if (stats?.type === "File") {89 if (stats?.type === "File") {
85 return candidate;90 return candidate.absolutePath;
86 }91 }
87 }92 }
88 return null;93 return null;
...@@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {...@@ -91,18 +96,31 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
91 const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(96 const resolvePath: ProjectFaviconResolverShape["resolvePath"] = Effect.fn(
92 "ProjectFaviconResolver.resolvePath",97 "ProjectFaviconResolver.resolvePath",
93 )(function* (cwd: string): Effect.fn.Return<string | null> {98 )(function* (cwd: string): Effect.fn.Return<string | null> {
99 const projectCwd = yield* workspacePaths
100 .normalizeWorkspaceRoot(cwd)
101 .pipe(Effect.orElseSucceed(() => null));
102 if (!projectCwd) {
103 return null;
104 }
94 for (const candidate of FAVICON_CANDIDATES) {105 for (const candidate of FAVICON_CANDIDATES) {
95 const resolved = path.join(cwd, candidate);106 const existing = yield* findExistingFile(projectCwd, [candidate]);
96 const existing = yield* findExistingFile(cwd, [resolved]);
97 if (existing) {107 if (existing) {
98 return existing;108 return existing;
99 }109 }
100 }110 }
101111
102 for (const sourceFile of ICON_SOURCE_FILES) {112 for (const sourceFile of ICON_SOURCE_FILES) {
103 const sourcePath = path.join(cwd, sourceFile);113 const sourcePath = yield* workspacePaths
114 .resolveRelativePathWithinRoot({
115 workspaceRoot: projectCwd,
116 relativePath: sourceFile,
117 })
118 .pipe(Effect.orElseSucceed(() => null));
119 if (!sourcePath) {
120 continue;
121 }
104 const source = yield* fileSystem122 const source = yield* fileSystem
105 .readFileString(sourcePath)123 .readFileString(sourcePath.absolutePath)
106 .pipe(Effect.orElseSucceed(() => null));124 .pipe(Effect.orElseSucceed(() => null));
107 if (!source) {125 if (!source) {
108 continue;126 continue;
...@@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {...@@ -111,7 +129,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () {
111 if (!href) {129 if (!href) {
112 continue;130 continue;
113 }131 }
114 const existing = yield* findExistingFile(cwd, resolveIconHref(cwd, href));132 const existing = yield* findExistingFile(projectCwd, resolveIconHref(href));
115 if (existing) {133 if (existing) {
116 return existing;134 return existing;
117 }135 }
apps/server/src/server.test.ts+1-168
...@@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");...@@ -71,7 +71,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
71import type { ServerConfigShape } from "./config.ts";71import type { ServerConfigShape } from "./config.ts";
72import { deriveServerPaths, ServerConfig } from "./config.ts";72import { deriveServerPaths, ServerConfig } from "./config.ts";
73import { makeRoutesLayer } from "./server.ts";73import { makeRoutesLayer } from "./server.ts";
74import { resolveAttachmentRelativePath } from "./attachmentPaths.ts";
75import {74import {
76 CheckpointDiffQuery,75 CheckpointDiffQuery,
77 type CheckpointDiffQueryShape,76 type CheckpointDiffQueryShape,
...@@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: {...@@ -516,7 +515,7 @@ const buildAppUnderTest = (options?: {
516 Layer.provide(WorkspacePathsLive),515 Layer.provide(WorkspacePathsLive),
517 Layer.provide(workspaceEntriesLayer),516 Layer.provide(workspaceEntriesLayer),
518 ),517 ),
519 ProjectFaviconResolverLive,518 ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)),
520 );519 );
521 const gitWorkflowLayer = GitWorkflowService.layer.pipe(520 const gitWorkflowLayer = GitWorkflowService.layer.pipe(
522 Layer.provideMerge(vcsDriverRegistryLayer),521 Layer.provideMerge(vcsDriverRegistryLayer),
...@@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1279,61 +1278,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1279 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1278 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1280 );1279 );
12811280
1282 it.effect("serves project favicon requests before the dev URL redirect", () =>
1283 Effect.gen(function* () {
1284 const fileSystem = yield* FileSystem.FileSystem;
1285 const path = yield* Path.Path;
1286 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
1287 prefix: "t3-router-project-favicon-",
1288 });
1289 yield* fileSystem.writeFileString(
1290 path.join(projectDir, "favicon.svg"),
1291 "<svg>router-project-favicon</svg>",
1292 );
1293
1294 yield* buildAppUnderTest({
1295 config: { devUrl: new URL("http://127.0.0.1:5173") },
1296 });
1297
1298 const response = yield* HttpClient.get(
1299 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
1300 {
1301 headers: {
1302 cookie: yield* getAuthenticatedSessionCookieHeader(),
1303 },
1304 },
1305 );
1306
1307 assert.equal(response.status, 200);
1308 assert.equal(yield* response.text, "<svg>router-project-favicon</svg>");
1309 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1310 );
1311
1312 it.effect("serves the fallback project favicon when no icon exists", () =>
1313 Effect.gen(function* () {
1314 const fileSystem = yield* FileSystem.FileSystem;
1315 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
1316 prefix: "t3-router-project-favicon-fallback-",
1317 });
1318
1319 yield* buildAppUnderTest({
1320 config: { devUrl: new URL("http://127.0.0.1:5173") },
1321 });
1322
1323 const response = yield* HttpClient.get(
1324 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`,
1325 {
1326 headers: {
1327 cookie: yield* getAuthenticatedSessionCookieHeader(),
1328 },
1329 },
1330 );
1331
1332 assert.equal(response.status, 200);
1333 assert.include(yield* response.text, 'data-fallback="project-favicon"');
1334 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1335 );
1336
1337 it.effect("serves the public environment descriptor without requiring auth", () =>1281 it.effect("serves the public environment descriptor without requiring auth", () =>
1338 Effect.gen(function* () {1282 Effect.gen(function* () {
1339 yield* buildAppUnderTest();1283 yield* buildAppUnderTest();
...@@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3195,28 +3139,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3195 },3139 },
3196 });3140 });
3197 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };3141 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
3198 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
3199 headers: {
3200 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3201 },
3202 });
3203 const faviconBody = (yield* faviconResponse.json) as {
3204 readonly _tag: string;
3205 readonly code: string;
3206 readonly requiredScope: string;
3207 readonly traceId: string;
3208 };
3209
3210 assert.equal(overbroadPairingResponse.status, 403);3142 assert.equal(overbroadPairingResponse.status, 403);
3211 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");3143 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
3212 assert.equal(pairingResponse.status, 200);3144 assert.equal(pairingResponse.status, 200);
3213 assert.equal(wsTicketResponse.status, 200);3145 assert.equal(wsTicketResponse.status, 200);
3214 assert.equal(faviconResponse.status, 403);
3215 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
3216 assert.equal(faviconBody.code, "insufficient_scope");
3217 assert.equal(faviconBody.requiredScope, "orchestration:read");
3218 assert.equal(typeof faviconBody.traceId, "string");
3219
3220 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;3146 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
3221 const rpcError = yield* Effect.flip(3147 const rpcError = yield* Effect.flip(
3222 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),3148 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
...@@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3760,29 +3686,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3760 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3686 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3761 );3687 );
37623688
3763 it.effect(
3764 "does not accept session tokens via query parameters on authenticated HTTP routes",
3765 () =>
3766 Effect.gen(function* () {
3767 const fileSystem = yield* FileSystem.FileSystem;
3768 const projectDir = yield* fileSystem.makeTempDirectoryScoped({
3769 prefix: "t3-router-project-favicon-query-token-",
3770 });
3771
3772 yield* buildAppUnderTest();
3773
3774 const { cookie } = yield* bootstrapBrowserSession();
3775 assert.isDefined(cookie);
3776 const sessionToken = extractSessionTokenFromSetCookie(cookie ?? "");
3777
3778 const response = yield* HttpClient.get(
3779 `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`,
3780 );
3781
3782 assert.equal(response.status, 401);
3783 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3784 );
3785
3786 it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () =>3689 it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () =>
3787 Effect.gen(function* () {3690 Effect.gen(function* () {
3788 yield* buildAppUnderTest();3691 yield* buildAppUnderTest();
...@@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -3853,60 +3756,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
3853 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3756 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3854 );3757 );
38553758
3856 it.effect("serves attachment files from state dir", () =>
3857 Effect.gen(function* () {
3858 const fileSystem = yield* FileSystem.FileSystem;
3859 const path = yield* Path.Path;
3860 const attachmentId = "thread-11111111-1111-4111-8111-111111111111";
3861
3862 const config = yield* buildAppUnderTest();
3863 const attachmentPath = resolveAttachmentRelativePath({
3864 attachmentsDir: config.attachmentsDir,
3865 relativePath: `${attachmentId}.bin`,
3866 });
3867 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
3868
3869 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
3870 yield* fileSystem.writeFileString(attachmentPath, "attachment-ok");
3871
3872 const response = yield* HttpClient.get(`/attachments/${attachmentId}`, {
3873 headers: {
3874 cookie: yield* getAuthenticatedSessionCookieHeader(),
3875 },
3876 });
3877 assert.equal(response.status, 200);
3878 assert.equal(yield* response.text, "attachment-ok");
3879 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3880 );
3881
3882 it.effect("serves attachment files for URL-encoded paths", () =>
3883 Effect.gen(function* () {
3884 const fileSystem = yield* FileSystem.FileSystem;
3885 const path = yield* Path.Path;
3886
3887 const config = yield* buildAppUnderTest();
3888 const attachmentPath = resolveAttachmentRelativePath({
3889 attachmentsDir: config.attachmentsDir,
3890 relativePath: "thread%20folder/message%20folder/file%20name.png",
3891 });
3892 assert.isNotNull(attachmentPath, "Attachment path should be resolvable");
3893
3894 yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true });
3895 yield* fileSystem.writeFileString(attachmentPath, "attachment-encoded-ok");
3896
3897 const response = yield* HttpClient.get(
3898 "/attachments/thread%20folder/message%20folder/file%20name.png",
3899 {
3900 headers: {
3901 cookie: yield* getAuthenticatedSessionCookieHeader(),
3902 },
3903 },
3904 );
3905 assert.equal(response.status, 200);
3906 assert.equal(yield* response.text, "attachment-encoded-ok");
3907 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3908 );
3909
3910 it.effect("proxies browser OTLP trace exports through the server", () =>3759 it.effect("proxies browser OTLP trace exports through the server", () =>
3911 Effect.gen(function* () {3760 Effect.gen(function* () {
3912 const upstreamRequests: Array<{3761 const upstreamRequests: Array<{
...@@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4196,22 +4045,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4196 }).pipe(Effect.provide(NodeHttpServer.layerTest)),4045 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4197 );4046 );
41984047
4199 it.effect("returns 404 for missing attachment id lookups", () =>
4200 Effect.gen(function* () {
4201 yield* buildAppUnderTest();
4202
4203 const response = yield* HttpClient.get(
4204 "/attachments/missing-11111111-1111-4111-8111-111111111111",
4205 {
4206 headers: {
4207 cookie: yield* getAuthenticatedSessionCookieHeader(),
4208 },
4209 },
4210 );
4211 assert.equal(response.status, 404);
4212 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4213 );
4214
4215 it.effect("routes websocket rpc server.upsertKeybinding", () =>4048 it.effect("routes websocket rpc server.upsertKeybinding", () =>
4216 Effect.gen(function* () {4049 Effect.gen(function* () {
4217 const rule: KeybindingRule = {4050 const rule: KeybindingRule = {
apps/server/src/server.ts+7-5
...@@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";...@@ -6,9 +6,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
7import { ServerConfig } from "./config.ts";7import { ServerConfig } from "./config.ts";
8import {8import {
9 attachmentsRouteLayer,
10 otlpTracesProxyRouteLayer,9 otlpTracesProxyRouteLayer,
11 projectFaviconRouteLayer,10 assetRouteLayer,
12 serverEnvironmentHttpApiLayer,11 serverEnvironmentHttpApiLayer,
13 staticAndDevRouteLayer,12 staticAndDevRouteLayer,
14 browserApiCorsLayer,13 browserApiCorsLayer,
...@@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll(...@@ -264,6 +263,10 @@ const WorkspaceLayerLive = Layer.mergeAll(
264 WorkspaceFileSystemLayerLive,263 WorkspaceFileSystemLayerLive,
265);264);
266265
266const ProjectFaviconResolverLayerLive = ProjectFaviconResolverLive.pipe(
267 Layer.provide(WorkspacePathsLive),
268);
269
267const AuthLayerLive = EnvironmentAuth.layer.pipe(270const AuthLayerLive = EnvironmentAuth.layer.pipe(
268 Layer.provideMerge(PersistenceLayerLive),271 Layer.provideMerge(PersistenceLayerLive),
269 Layer.provide(ServerSecretStore.layer),272 Layer.provide(ServerSecretStore.layer),
...@@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -313,7 +316,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
313 Layer.provideMerge(OpenCodeRuntimeLive),316 Layer.provideMerge(OpenCodeRuntimeLive),
314 Layer.provideMerge(ServerSettingsLive),317 Layer.provideMerge(ServerSettingsLive),
315 Layer.provideMerge(WorkspaceLayerLive),318 Layer.provideMerge(WorkspaceLayerLive),
316 Layer.provideMerge(ProjectFaviconResolverLive),319 Layer.provideMerge(ProjectFaviconResolverLayerLive),
317 Layer.provideMerge(RepositoryIdentityResolverLive),320 Layer.provideMerge(RepositoryIdentityResolverLive),
318 Layer.provideMerge(ServerEnvironmentLive),321 Layer.provideMerge(ServerEnvironmentLive),
319 Layer.provideMerge(AuthLayerLive),322 Layer.provideMerge(AuthLayerLive),
...@@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll(...@@ -350,9 +353,8 @@ export const makeRoutesLayer = Layer.mergeAll(
350 Layer.provide(serverEnvironmentHttpApiLayer),353 Layer.provide(serverEnvironmentHttpApiLayer),
351 Layer.provide(environmentAuthenticatedAuthLayer),354 Layer.provide(environmentAuthenticatedAuthLayer),
352 ),355 ),
353 attachmentsRouteLayer,
354 otlpTracesProxyRouteLayer,356 otlpTracesProxyRouteLayer,
355 projectFaviconRouteLayer,357 assetRouteLayer,
356 staticAndDevRouteLayer,358 staticAndDevRouteLayer,
357 websocketRpcRouteLayer,359 websocketRpcRouteLayer,
358 ),360 ),
apps/server/src/ws.ts+49
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 type RelayClientInstallProgressEvent,40 type RelayClientInstallProgressEvent,
41 OrchestrationReplayEventsError,41 OrchestrationReplayEventsError,
42 FilesystemBrowseError,42 FilesystemBrowseError,
43 AssetAccessError,
43 EnvironmentAuthorizationError,44 EnvironmentAuthorizationError,
44 ThreadId,45 ThreadId,
45 type TerminalAttachStreamEvent,46 type TerminalAttachStreamEvent,
...@@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe...@@ -73,6 +74,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe
73import { TerminalManager } from "./terminal/Services/Manager.ts";74import { TerminalManager } from "./terminal/Services/Manager.ts";
74import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";75import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
75import * as PreviewManager from "./preview/Manager.ts";76import * as PreviewManager from "./preview/Manager.ts";
77import { issueAssetUrl } from "./assets/AssetAccess.ts";
76import * as PortScanner from "./preview/PortScanner.ts";78import * as PortScanner from "./preview/PortScanner.ts";
77import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";79import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
78import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";80import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
...@@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([...@@ -162,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
162 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],164 [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope],
163 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],165 [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope],
164 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],166 [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope],
167 [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope],
165 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],168 [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope],
166 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],169 [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope],
167 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],170 [WS_METHODS.vcsPull, AuthOrchestrationOperateScope],
...@@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>...@@ -1203,6 +1206,52 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
1203 ),1206 ),
1204 { "rpc.aggregate": "workspace" },1207 { "rpc.aggregate": "workspace" },
1205 ),1208 ),
1209 [WS_METHODS.assetsCreateUrl]: (input) =>
1210 observeRpcEffect(
1211 WS_METHODS.assetsCreateUrl,
1212 Effect.gen(function* () {
1213 if (input.resource._tag !== "workspace-file") {
1214 return yield* issueAssetUrl({ resource: input.resource });
1215 }
1216 const thread = yield* projectionSnapshotQuery
1217 .getThreadShellById(input.resource.threadId)
1218 .pipe(
1219 Effect.mapError(
1220 (cause) =>
1221 new AssetAccessError({
1222 message: "Failed to resolve workspace context.",
1223 cause,
1224 }),
1225 ),
1226 );
1227 if (Option.isNone(thread)) {
1228 return yield* new AssetAccessError({
1229 message: "Workspace context was not found.",
1230 });
1231 }
1232 const project = yield* projectionSnapshotQuery
1233 .getProjectShellById(thread.value.projectId)
1234 .pipe(
1235 Effect.mapError(
1236 (cause) =>
1237 new AssetAccessError({
1238 message: "Failed to resolve workspace context.",
1239 cause,
1240 }),
1241 ),
1242 );
1243 if (Option.isNone(project)) {
1244 return yield* new AssetAccessError({
1245 message: "Workspace context was not found.",
1246 });
1247 }
1248 return yield* issueAssetUrl({
1249 resource: input.resource,
1250 workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot,
1251 });
1252 }),
1253 { "rpc.aggregate": "workspace" },
1254 ),
1206 [WS_METHODS.subscribeVcsStatus]: (input) =>1255 [WS_METHODS.subscribeVcsStatus]: (input) =>
1207 observeRpcStream(1256 observeRpcStream(
1208 WS_METHODS.subscribeVcsStatus,1257 WS_METHODS.subscribeVcsStatus,
apps/web/src/assets/assetUrls.ts created+89
...@@ -0,0 +1,89 @@
1import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
2import { useEffect, useMemo, useState } from "react";
3
4import { readEnvironmentApi } from "~/environmentApi";
5import { readEnvironmentConnection } from "~/environments/runtime";
6
7const REFRESH_MARGIN_MS = 30_000;
8
9interface CachedAssetUrl {
10 readonly url: string;
11 readonly expiresAt: number;
12}
13
14const assetUrlCache = new Map<string, CachedAssetUrl>();
15const assetUrlRequests = new Map<string, Promise<CachedAssetUrl>>();
16
17function assetCacheKey(environmentId: EnvironmentId, resource: AssetResource): string {
18 return `${environmentId}:${JSON.stringify(resource)}`;
19}
20
21export async function resolveAssetUrl(
22 environmentId: EnvironmentId,
23 resource: AssetResource,
24): Promise<CachedAssetUrl> {
25 const key = assetCacheKey(environmentId, resource);
26 const cached = assetUrlCache.get(key);
27 if (cached && cached.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
28 return cached;
29 }
30
31 const inFlight = assetUrlRequests.get(key);
32 if (inFlight) {
33 return inFlight;
34 }
35
36 const request = (async () => {
37 const api = readEnvironmentApi(environmentId);
38 const connection = readEnvironmentConnection(environmentId);
39 if (!api || !connection) {
40 throw new Error("Environment is not connected.");
41 }
42 const result = await api.assets.createUrl({ resource });
43 const cachedResult = {
44 url: new URL(result.relativeUrl, connection.knownEnvironment.target.httpBaseUrl).toString(),
45 expiresAt: result.expiresAt,
46 };
47 assetUrlCache.set(key, cachedResult);
48 return cachedResult;
49 })().finally(() => {
50 assetUrlRequests.delete(key);
51 });
52 assetUrlRequests.set(key, request);
53 return request;
54}
55
56export function useAssetUrl(environmentId: EnvironmentId, resource: AssetResource): string | null {
57 const resourceJson = JSON.stringify(resource);
58 const stableResource = useMemo(() => JSON.parse(resourceJson) as AssetResource, [resourceJson]);
59 const key = assetCacheKey(environmentId, stableResource);
60 const [url, setUrl] = useState<string | null>(() => assetUrlCache.get(key)?.url ?? null);
61
62 useEffect(() => {
63 let cancelled = false;
64 let refreshTimer: ReturnType<typeof setTimeout> | undefined;
65
66 const load = () => {
67 void resolveAssetUrl(environmentId, stableResource)
68 .then((result) => {
69 if (cancelled) return;
70 setUrl(result.url);
71 refreshTimer = setTimeout(
72 load,
73 Math.max(0, result.expiresAt - Date.now() - REFRESH_MARGIN_MS),
74 );
75 })
76 .catch(() => {
77 if (!cancelled) setUrl(null);
78 });
79 };
80 load();
81
82 return () => {
83 cancelled = true;
84 if (refreshTimer) clearTimeout(refreshTimer);
85 };
86 }, [environmentId, key, stableResource]);
87
88 return url;
89}
apps/web/src/browser/openFileInPreview.ts created+36
...@@ -0,0 +1,36 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { readEnvironmentApi } from "~/environmentApi";
4import { resolveAssetUrl } from "~/assets/assetUrls";
5import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore";
6import { useRightPanelStore } from "~/rightPanelStore";
7
8export const isBrowserPreviewFile = (path: string): boolean =>
9 /\.(?:html?|pdf)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
10
11export async function openUrlInPreview(threadRef: ScopedThreadRef, url: string): Promise<void> {
12 const api = readEnvironmentApi(threadRef.environmentId);
13 if (!api) {
14 throw new Error("Environment is not connected.");
15 }
16
17 const snapshot = await api.preview.open({ threadId: threadRef.threadId, url });
18 usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot);
19 usePreviewStateStore.getState().rememberUrl(threadRef, url);
20 useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId);
21}
22
23export async function openFileInPreview(
24 threadRef: ScopedThreadRef,
25 filePath: string,
26): Promise<void> {
27 if (!isPreviewSupportedInRuntime()) {
28 throw new Error("The integrated browser is unavailable in this runtime.");
29 }
30 const asset = await resolveAssetUrl(threadRef.environmentId, {
31 _tag: "workspace-file",
32 threadId: threadRef.threadId,
33 path: filePath,
34 });
35 await openUrlInPreview(threadRef, asset.url);
36}
apps/web/src/components/ChatMarkdown.browser.tsx+95-2
...@@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser";...@@ -4,11 +4,24 @@ import { page } from "vite-plus/test/browser";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5import { render } from "vitest-browser-react";5import { render } from "vitest-browser-react";
66
7const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({7const {
8 contextMenuShowMock,
9 openFileInPreviewMock,
10 openInPreferredEditorMock,
11 openUrlInPreviewMock,
12 readLocalApiMock,
13} = vi.hoisted(() => ({
14 contextMenuShowMock: vi.fn(),
15 openFileInPreviewMock: vi.fn(async () => undefined),
8 openInPreferredEditorMock: vi.fn(async () => "vscode"),16 openInPreferredEditorMock: vi.fn(async () => "vscode"),
17 openUrlInPreviewMock: vi.fn(async () => undefined),
9 readLocalApiMock: vi.fn(() => ({18 readLocalApiMock: vi.fn(() => ({
19 contextMenu: { show: contextMenuShowMock },
10 server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },20 server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },
11 shell: { openInEditor: vi.fn(async () => undefined) },21 shell: {
22 openExternal: vi.fn(async () => undefined),
23 openInEditor: vi.fn(async () => undefined),
24 },
12 })),25 })),
13}));26}));
1427
...@@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({...@@ -23,12 +36,32 @@ vi.mock("../localApi", () => ({
23 readLocalApi: readLocalApiMock,36 readLocalApi: readLocalApiMock,
24}));37}));
2538
39vi.mock("../previewStateStore", async (importOriginal) => ({
40 ...(await importOriginal<typeof import("../previewStateStore")>()),
41 isPreviewSupportedInRuntime: () => true,
42}));
43
44vi.mock("../browser/openFileInPreview", async (importOriginal) => ({
45 ...(await importOriginal<typeof import("../browser/openFileInPreview")>()),
46 openFileInPreview: openFileInPreviewMock,
47 openUrlInPreview: openUrlInPreviewMock,
48}));
49
26import ChatMarkdown from "./ChatMarkdown";50import ChatMarkdown from "./ChatMarkdown";
27import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";51import { serializeTableElementToCsv, serializeTableElementToMarkdown } from "../markdown-clipboard";
52import { EnvironmentId, ThreadId } from "@t3tools/contracts";
53
54const threadRef = {
55 environmentId: EnvironmentId.make("environment-test"),
56 threadId: ThreadId.make("thread-test"),
57};
2858
29describe("ChatMarkdown", () => {59describe("ChatMarkdown", () => {
30 afterEach(() => {60 afterEach(() => {
31 openInPreferredEditorMock.mockClear();61 openInPreferredEditorMock.mockClear();
62 openFileInPreviewMock.mockClear();
63 openUrlInPreviewMock.mockClear();
64 contextMenuShowMock.mockReset();
32 readLocalApiMock.mockClear();65 readLocalApiMock.mockClear();
33 localStorage.clear();66 localStorage.clear();
34 document.body.innerHTML = "";67 document.body.innerHTML = "";
...@@ -155,6 +188,66 @@ describe("ChatMarkdown", () => {...@@ -155,6 +188,66 @@ describe("ChatMarkdown", () => {
155 }188 }
156 });189 });
157190
191 it("opens web links in the integrated browser from the context menu", async () => {
192 contextMenuShowMock.mockResolvedValue("open-in-browser");
193 const screen = await render(
194 <ChatMarkdown
195 text="[OpenAI](https://openai.com/docs)"
196 cwd="/repo/project"
197 threadRef={threadRef}
198 />,
199 );
200
201 try {
202 const link = page.getByRole("link", { name: "OpenAI" }).element();
203 link.dispatchEvent(
204 new MouseEvent("contextmenu", {
205 bubbles: true,
206 cancelable: true,
207 clientX: 12,
208 clientY: 24,
209 }),
210 );
211
212 await vi.waitFor(() => {
213 expect(contextMenuShowMock).toHaveBeenCalled();
214 expect(openUrlInPreviewMock).toHaveBeenCalledWith(threadRef, "https://openai.com/docs");
215 });
216 } finally {
217 await screen.unmount();
218 }
219 });
220
221 it("offers integrated browser opening for HTML file links", async () => {
222 contextMenuShowMock.mockResolvedValue("open-in-browser");
223 const filePath = "/repo/project/report.html";
224 const screen = await render(
225 <ChatMarkdown text="[report.html](report.html)" cwd="/repo/project" threadRef={threadRef} />,
226 );
227
228 try {
229 const link = page.getByRole("link", { name: "report.html" }).element();
230 link.dispatchEvent(
231 new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 8 }),
232 );
233
234 await vi.waitFor(() => {
235 expect(contextMenuShowMock).toHaveBeenCalledWith(
236 expect.arrayContaining([
237 expect.objectContaining({
238 id: "open-in-browser",
239 label: "Open in integrated browser",
240 }),
241 ]),
242 { x: 4, y: 8 },
243 );
244 expect(openFileInPreviewMock).toHaveBeenCalledWith(threadRef, filePath);
245 });
246 } finally {
247 await screen.unmount();
248 }
249 });
250
158 it("keeps a favicon with the leading segment of a wrapping URL", async () => {251 it("keeps a favicon with the leading segment of a wrapping URL", async () => {
159 const url = "https://github.com/pingdotgg/t3code/pull/3017/changes";252 const url = "https://github.com/pingdotgg/t3code/pull/3017/changes";
160 const screen = await render(253 const screen = await render(
apps/web/src/components/ChatMarkdown.tsx+90-5
...@@ -8,7 +8,7 @@ import {...@@ -8,7 +8,7 @@ import {
8 Minimize2Icon,8 Minimize2Icon,
9 WrapTextIcon,9 WrapTextIcon,
10} from "lucide-react";10} from "lucide-react";
11import type { ServerProviderSkill } from "@t3tools/contracts";11import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts";
12import React, {12import React, {
13 Children,13 Children,
14 Suspense,14 Suspense,
...@@ -62,6 +62,12 @@ import {...@@ -62,6 +62,12 @@ import {
62} from "../markdown-links";62} from "../markdown-links";
63import { readLocalApi } from "../localApi";63import { readLocalApi } from "../localApi";
64import { cn } from "../lib/utils";64import { cn } from "../lib/utils";
65import { isPreviewSupportedInRuntime } from "../previewStateStore";
66import {
67 isBrowserPreviewFile,
68 openFileInPreview,
69 openUrlInPreview,
70} from "../browser/openFileInPreview";
6571
66class CodeHighlightErrorBoundary extends React.Component<72class CodeHighlightErrorBoundary extends React.Component<
67 { fallback: ReactNode; children: ReactNode },73 { fallback: ReactNode; children: ReactNode },
...@@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component<...@@ -87,6 +93,7 @@ class CodeHighlightErrorBoundary extends React.Component<
87interface ChatMarkdownProps {93interface ChatMarkdownProps {
88 text: string;94 text: string;
89 cwd: string | undefined;95 cwd: string | undefined;
96 threadRef?: ScopedThreadRef | undefined;
90 isStreaming?: boolean;97 isStreaming?: boolean;
91 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;98 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
92 className?: string;99 className?: string;
...@@ -670,6 +677,7 @@ interface MarkdownFileLinkProps {...@@ -670,6 +677,7 @@ interface MarkdownFileLinkProps {
670 label: string;677 label: string;
671 copyMarkdown: string;678 copyMarkdown: string;
672 theme: "light" | "dark";679 theme: "light" | "dark";
680 threadRef?: ScopedThreadRef | undefined;
673 className?: string | undefined;681 className?: string | undefined;
674}682}
675683
...@@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({...@@ -936,6 +944,54 @@ function MarkdownExternalLinkContent({
936 );944 );
937}945}
938946
947function MarkdownExternalLink({
948 href,
949 threadRef,
950 children,
951 ...props
952}: React.ComponentProps<"a"> & {
953 href: string;
954 threadRef?: ScopedThreadRef | undefined;
955}) {
956 const handleContextMenu = useCallback(
957 async (event: ReactMouseEvent<HTMLAnchorElement>) => {
958 if (!threadRef || !isPreviewSupportedInRuntime()) return;
959 event.preventDefault();
960 event.stopPropagation();
961
962 const api = readLocalApi();
963 if (!api) return;
964 const clicked = await api.contextMenu.show(
965 [
966 { id: "open-in-browser", label: "Open in integrated browser" },
967 { id: "open-external", label: "Open in system browser" },
968 ] as const,
969 { x: event.clientX, y: event.clientY },
970 );
971 if (clicked === "open-in-browser") {
972 void openUrlInPreview(threadRef, href).catch((error) => {
973 toastManager.add(
974 stackedThreadToast({
975 type: "error",
976 title: "Unable to open link in browser",
977 description: error instanceof Error ? error.message : "An error occurred.",
978 }),
979 );
980 });
981 } else if (clicked === "open-external") {
982 void api.shell.openExternal(href);
983 }
984 },
985 [href, threadRef],
986 );
987
988 return (
989 <a {...props} href={href} onContextMenu={handleContextMenu}>
990 {children}
991 </a>
992 );
993}
994
939const MarkdownFileLink = memo(function MarkdownFileLink({995const MarkdownFileLink = memo(function MarkdownFileLink({
940 href,996 href,
941 targetPath,997 targetPath,
...@@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -944,6 +1000,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
944 label,1000 label,
945 copyMarkdown,1001 copyMarkdown,
946 theme,1002 theme,
1003 threadRef,
947 className,1004 className,
948}: MarkdownFileLinkProps) {1005}: MarkdownFileLinkProps) {
949 const handleOpen = useCallback(() => {1006 const handleOpen = useCallback(() => {
...@@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -967,6 +1024,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
967 });1024 });
968 }, [targetPath]);1025 }, [targetPath]);
9691026
1027 const handleOpenInBrowser = useCallback(() => {
1028 if (!threadRef) return;
1029 void openFileInPreview(threadRef, iconPath).catch((error) => {
1030 toastManager.add(
1031 stackedThreadToast({
1032 type: "error",
1033 title: "Unable to open file in browser",
1034 description: error instanceof Error ? error.message : "An error occurred.",
1035 }),
1036 );
1037 });
1038 }, [iconPath, threadRef]);
1039
970 const handleCopy = useCallback((value: string, title: string) => {1040 const handleCopy = useCallback((value: string, title: string) => {
971 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {1041 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
972 toastManager.add(1042 toastManager.add(
...@@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1007,9 +1077,14 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1007 const api = readLocalApi();1077 const api = readLocalApi();
1008 if (!api) return;1078 if (!api) return;
10091079
1080 const canOpenInBrowser =
1081 Boolean(threadRef) && isPreviewSupportedInRuntime() && isBrowserPreviewFile(iconPath);
1010 const clicked = await api.contextMenu.show(1082 const clicked = await api.contextMenu.show(
1011 [1083 [
1012 { id: "open", label: "Open in editor" },1084 { id: "open", label: "Open in editor" },
1085 ...(canOpenInBrowser
1086 ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
1087 : []),
1013 { id: "copy-relative", label: "Copy relative path" },1088 { id: "copy-relative", label: "Copy relative path" },
1014 { id: "copy-full", label: "Copy full path" },1089 { id: "copy-full", label: "Copy full path" },
1015 ] as const,1090 ] as const,
...@@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1020,6 +1095,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1020 handleOpen();1095 handleOpen();
1021 return;1096 return;
1022 }1097 }
1098 if (clicked === "open-in-browser") {
1099 handleOpenInBrowser();
1100 return;
1101 }
1023 if (clicked === "copy-relative") {1102 if (clicked === "copy-relative") {
1024 handleCopy(displayPath, "Relative path");1103 handleCopy(displayPath, "Relative path");
1025 return;1104 return;
...@@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1028,7 +1107,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1028 handleCopy(targetPath, "Full path");1107 handleCopy(targetPath, "Full path");
1029 }1108 }
1030 },1109 },
1031 [displayPath, handleCopy, handleOpen, targetPath],1110 [displayPath, handleCopy, handleOpen, handleOpenInBrowser, iconPath, targetPath, threadRef],
1032 );1111 );
10331112
1034 return (1113 return (
...@@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual(...@@ -1074,6 +1153,8 @@ function areMarkdownFileLinkPropsEqual(
1074 previous.label === next.label &&1153 previous.label === next.label &&
1075 previous.copyMarkdown === next.copyMarkdown &&1154 previous.copyMarkdown === next.copyMarkdown &&
1076 previous.theme === next.theme &&1155 previous.theme === next.theme &&
1156 previous.threadRef?.environmentId === next.threadRef?.environmentId &&
1157 previous.threadRef?.threadId === next.threadRef?.threadId &&
1077 previous.className === next.className1158 previous.className === next.className
1078 );1159 );
1079}1160}
...@@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1081,6 +1162,7 @@ function areMarkdownFileLinkPropsEqual(
1081function ChatMarkdown({1162function ChatMarkdown({
1082 text,1163 text,
1083 cwd,1164 cwd,
1165 threadRef,
1084 isStreaming = false,1166 isStreaming = false,
1085 skills = EMPTY_MARKDOWN_SKILLS,1167 skills = EMPTY_MARKDOWN_SKILLS,
1086 className,1168 className,
...@@ -1137,9 +1219,10 @@ function ChatMarkdown({...@@ -1137,9 +1219,10 @@ function ChatMarkdown({
1137 const isSameDocumentLink = href?.startsWith("#") ?? false;1219 const isSameDocumentLink = href?.startsWith("#") ?? false;
1138 const onClick = props.onClick;1220 const onClick = props.onClick;
1139 const link = (1221 const link = (
1140 <a1222 <MarkdownExternalLink
1141 {...props}1223 {...props}
1142 href={href}1224 href={href ?? ""}
1225 threadRef={faviconHost && isPreviewSupportedInRuntime() ? threadRef : undefined}
1143 target={isSameDocumentLink ? undefined : "_blank"}1226 target={isSameDocumentLink ? undefined : "_blank"}
1144 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}1227 rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
1145 onClick={(event) => {1228 onClick={(event) => {
...@@ -1156,7 +1239,7 @@ function ChatMarkdown({...@@ -1156,7 +1239,7 @@ function ChatMarkdown({
1156 ) : (1239 ) : (
1157 children1240 children
1158 )}1241 )}
1159 </a>1242 </MarkdownExternalLink>
1160 );1243 );
1161 if (!faviconHost || !href) {1244 if (!faviconHost || !href) {
1162 return link;1245 return link;
...@@ -1194,6 +1277,7 @@ function ChatMarkdown({...@@ -1194,6 +1277,7 @@ function ChatMarkdown({
1194 label={labelParts.join(" · ")}1277 label={labelParts.join(" · ")}
1195 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}1278 copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`}
1196 theme={resolvedTheme}1279 theme={resolvedTheme}
1280 threadRef={threadRef}
1197 className={props.className}1281 className={props.className}
1198 />1282 />
1199 );1283 );
...@@ -1238,6 +1322,7 @@ function ChatMarkdown({...@@ -1238,6 +1322,7 @@ function ChatMarkdown({
1238 fileLinkParentSuffixByPath,1322 fileLinkParentSuffixByPath,
1239 isStreaming,1323 isStreaming,
1240 markdownFileLinkMetaByHref,1324 markdownFileLinkMetaByHref,
1325 threadRef,
1241 resolvedTheme,1326 resolvedTheme,
1242 skills,1327 skills,
1243 ],1328 ],
apps/web/src/components/ChatView.browser.tsx+13-3
...@@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: {...@@ -250,6 +250,18 @@ function createMockEnvironmentApi(input: {
250 filesystem: {250 filesystem: {
251 browse: input.browse,251 browse: input.browse,
252 },252 },
253 assets: {
254 createUrl: vi.fn(async ({ resource }) => ({
255 relativeUrl: `/api/assets/test/${encodeURIComponent(
256 resource._tag === "attachment"
257 ? resource.attachmentId
258 : resource._tag === "project-favicon"
259 ? "favicon.svg"
260 : (resource.path.split(/[\\/]/).at(-1) ?? "asset"),
261 )}`,
262 expiresAt: Date.now() + 60_000,
263 })),
264 },
253 sourceControl: {} as EnvironmentApi["sourceControl"],265 sourceControl: {} as EnvironmentApi["sourceControl"],
254 vcs: {} as EnvironmentApi["vcs"],266 vcs: {} as EnvironmentApi["vcs"],
255 git: {} as EnvironmentApi["git"],267 git: {} as EnvironmentApi["git"],
...@@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: {...@@ -373,7 +385,6 @@ function createSnapshotForTargetUser(options: {
373 name: `attachment-${attachmentIndex + 1}.png`,385 name: `attachment-${attachmentIndex + 1}.png`,
374 mimeType: "image/png",386 mimeType: "image/png",
375 sizeBytes: 128,387 sizeBytes: 128,
376 previewUrl: `/attachments/attachment-${attachmentIndex + 1}`,
377 }))388 }))
378 : undefined;389 : undefined;
379390
...@@ -1153,14 +1164,13 @@ const worker = setupWorker(...@@ -1153,14 +1164,13 @@ const worker = setupWorker(
1153 });1164 });
1154 }),1165 }),
1155 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),1166 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
1156 http.get("*/attachments/:attachmentId", () =>1167 http.get("*/api/assets/test/:assetName", () =>
1157 HttpResponse.text(ATTACHMENT_SVG, {1168 HttpResponse.text(ATTACHMENT_SVG, {
1158 headers: {1169 headers: {
1159 "Content-Type": "image/svg+xml",1170 "Content-Type": "image/svg+xml",
1160 },1171 },
1161 }),1172 }),
1162 ),1173 ),
1163 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
1164);1174);
11651175
1166async function nextFrame(): Promise<void> {1176async function nextFrame(): Promise<void> {
apps/web/src/components/ChatView.tsx+67-5
...@@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow";...@@ -37,6 +37,7 @@ import { useShallow } from "zustand/react/shallow";
37import { useVcsStatus } from "~/lib/vcsStatusState";37import { useVcsStatus } from "~/lib/vcsStatusState";
38import { usePrimaryEnvironmentId } from "../environments/primary";38import { usePrimaryEnvironmentId } from "../environments/primary";
39import { readEnvironmentApi } from "../environmentApi";39import { readEnvironmentApi } from "../environmentApi";
40import { resolveAssetUrl } from "../assets/assetUrls";
40import { isElectron } from "../env";41import { isElectron } from "../env";
41import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";42import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner";
42import { readLocalApi } from "../localApi";43import { readLocalApi } from "../localApi";
...@@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) {...@@ -1915,8 +1916,64 @@ export default function ChatView(props: ChatViewProps) {
1915 });1916 });
1916 }, []);1917 }, []);
1917 const serverMessages = activeThread?.messages;1918 const serverMessages = activeThread?.messages;
1919 const [attachmentAssetUrlById, setAttachmentAssetUrlById] = useState<Record<string, string>>({});
1918 useEffect(() => {1920 useEffect(() => {
1919 if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) {1921 if (!serverMessages) return;
1922 const attachmentIds = [
1923 ...new Set(
1924 serverMessages.flatMap(
1925 (message) =>
1926 message.attachments?.flatMap((attachment) =>
1927 attachment.type === "image" && !attachment.previewUrl ? [attachment.id] : [],
1928 ) ?? [],
1929 ),
1930 ),
1931 ].filter((attachmentId) => !attachmentAssetUrlById[attachmentId]);
1932 if (attachmentIds.length === 0) return;
1933
1934 let cancelled = false;
1935 void Promise.all(
1936 attachmentIds.map(async (attachmentId) => {
1937 const asset = await resolveAssetUrl(environmentId, {
1938 _tag: "attachment",
1939 attachmentId,
1940 });
1941 return [attachmentId, asset.url] as const;
1942 }),
1943 )
1944 .then((entries) => {
1945 if (!cancelled) {
1946 setAttachmentAssetUrlById((current) => ({ ...current, ...Object.fromEntries(entries) }));
1947 }
1948 })
1949 .catch(() => undefined);
1950
1951 return () => {
1952 cancelled = true;
1953 };
1954 }, [attachmentAssetUrlById, environmentId, serverMessages]);
1955 const serverMessagesWithAssetUrls = useMemo(() => {
1956 if (!serverMessages || Object.keys(attachmentAssetUrlById).length === 0) {
1957 return serverMessages;
1958 }
1959 return serverMessages.map((message) => {
1960 if (!message.attachments) return message;
1961 let changed = false;
1962 const attachments = message.attachments.map((attachment) => {
1963 const previewUrl = attachmentAssetUrlById[attachment.id];
1964 if (!previewUrl || attachment.previewUrl === previewUrl) return attachment;
1965 changed = true;
1966 return { ...attachment, previewUrl };
1967 });
1968 return changed ? { ...message, attachments } : message;
1969 });
1970 }, [attachmentAssetUrlById, serverMessages]);
1971 useEffect(() => {
1972 if (
1973 typeof Image === "undefined" ||
1974 !serverMessagesWithAssetUrls ||
1975 serverMessagesWithAssetUrls.length === 0
1976 ) {
1920 return;1977 return;
1921 }1978 }
19221979
...@@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1929,7 +1986,7 @@ export default function ChatView(props: ChatViewProps) {
1929 continue;1986 continue;
1930 }1987 }
19311988
1932 const serverMessage = serverMessages.find(1989 const serverMessage = serverMessagesWithAssetUrls.find(
1933 (message) => message.id === messageId && message.role === "user",1990 (message) => message.id === messageId && message.role === "user",
1934 );1991 );
1935 if (!serverMessage?.attachments || serverMessage.attachments.length === 0) {1992 if (!serverMessage?.attachments || serverMessage.attachments.length === 0) {
...@@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -1995,9 +2052,13 @@ export default function ChatView(props: ChatViewProps) {
1995 cleanup();2052 cleanup();
1996 }2053 }
1997 };2054 };
1998 }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]);2055 }, [
2056 attachmentPreviewHandoffByMessageId,
2057 clearAttachmentPreviewHandoff,
2058 serverMessagesWithAssetUrls,
2059 ]);
1999 const timelineMessages = useMemo(() => {2060 const timelineMessages = useMemo(() => {
2000 const messages = serverMessages ?? [];2061 const messages = serverMessagesWithAssetUrls ?? [];
2001 const serverMessagesWithPreviewHandoff =2062 const serverMessagesWithPreviewHandoff =
2002 Object.keys(attachmentPreviewHandoffByMessageId).length === 02063 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
2003 ? messages2064 ? messages
...@@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2048,7 +2109,7 @@ export default function ChatView(props: ChatViewProps) {
2048 return serverMessagesWithPreviewHandoff;2109 return serverMessagesWithPreviewHandoff;
2049 }2110 }
2050 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];2111 return [...serverMessagesWithPreviewHandoff, ...pendingMessages];
2051 }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);2112 }, [serverMessagesWithAssetUrls, attachmentPreviewHandoffByMessageId, optimisticUserMessages]);
2052 const timelineEntries = useMemo(2113 const timelineEntries = useMemo(
2053 () =>2114 () =>
2054 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),2115 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
...@@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -4760,6 +4821,7 @@ export default function ChatView(props: ChatViewProps) {
4760 activeProposedPlan={sidebarProposedPlan}4821 activeProposedPlan={sidebarProposedPlan}
4761 label={planSidebarLabel}4822 label={planSidebarLabel}
4762 environmentId={environmentId}4823 environmentId={environmentId}
4824 threadRef={activeThreadRef}
4763 markdownCwd={gitCwd ?? undefined}4825 markdownCwd={gitCwd ?? undefined}
4764 workspaceRoot={activeWorkspaceRoot}4826 workspaceRoot={activeWorkspaceRoot}
4765 timestampFormat={timestampFormat}4827 timestampFormat={timestampFormat}
apps/web/src/components/KeybindingsToast.browser.tsx+1-2
...@@ -322,8 +322,7 @@ const worker = setupWorker(...@@ -322,8 +322,7 @@ const worker = setupWorker(
322 });322 });
323 }),323 }),
324 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),324 ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth),
325 http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })),325 http.get("*/api/assets/*", () => new HttpResponse(null, { status: 204 })),
326 http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })),
327);326);
328327
329function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {328function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) {
apps/web/src/components/PlanSidebar.tsx+4-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo, useState, useCallback } from "react";1import { memo, useState, useCallback } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import { type TimestampFormat } from "@t3tools/contracts/settings";3import { type TimestampFormat } from "@t3tools/contracts/settings";
4import { Badge } from "./ui/badge";4import { Badge } from "./ui/badge";
5import { Button } from "./ui/button";5import { Button } from "./ui/button";
...@@ -56,6 +56,7 @@ interface PlanSidebarProps {...@@ -56,6 +56,7 @@ interface PlanSidebarProps {
56 activeProposedPlan: LatestProposedPlanState | null;56 activeProposedPlan: LatestProposedPlanState | null;
57 label?: string;57 label?: string;
58 environmentId: EnvironmentId;58 environmentId: EnvironmentId;
59 threadRef?: ScopedThreadRef | undefined;
59 markdownCwd: string | undefined;60 markdownCwd: string | undefined;
60 workspaceRoot: string | undefined;61 workspaceRoot: string | undefined;
61 timestampFormat: TimestampFormat;62 timestampFormat: TimestampFormat;
...@@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -68,6 +69,7 @@ const PlanSidebar = memo(function PlanSidebar({
68 activeProposedPlan,69 activeProposedPlan,
69 label = "Plan",70 label = "Plan",
70 environmentId,71 environmentId,
72 threadRef,
71 markdownCwd,73 markdownCwd,
72 workspaceRoot,74 workspaceRoot,
73 timestampFormat,75 timestampFormat,
...@@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -257,6 +259,7 @@ const PlanSidebar = memo(function PlanSidebar({
257 <ChatMarkdown259 <ChatMarkdown
258 text={displayedPlanMarkdown ?? ""}260 text={displayedPlanMarkdown ?? ""}
259 cwd={markdownCwd}261 cwd={markdownCwd}
262 threadRef={threadRef}
260 isStreaming={false}263 isStreaming={false}
261 />264 />
262 </div>265 </div>
apps/web/src/components/ProjectFavicon.tsx+9-13
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId } from "@t3tools/contracts";
2import { FolderIcon } from "lucide-react";2import { FolderIcon } from "lucide-react";
3import { useState } from "react";3import { useEffect, useState } from "react";
4import { resolveEnvironmentHttpUrl } from "../environments/runtime";4import { useAssetUrl } from "../assets/assetUrls";
55
6const loadedProjectFaviconSrcs = new Set<string>();6const loadedProjectFaviconSrcs = new Set<string>();
77
...@@ -10,20 +10,16 @@ export function ProjectFavicon(input: {...@@ -10,20 +10,16 @@ export function ProjectFavicon(input: {
10 cwd: string;10 cwd: string;
11 className?: string;11 className?: string;
12}) {12}) {
13 const src = (() => {13 const src = useAssetUrl(input.environmentId, {
14 try {14 _tag: "project-favicon",
15 return resolveEnvironmentHttpUrl({15 cwd: input.cwd,
16 environmentId: input.environmentId,16 });
17 pathname: "/api/project-favicon",
18 searchParams: { cwd: input.cwd },
19 });
20 } catch {
21 return null;
22 }
23 })();
24 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>17 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
25 src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",18 src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
26 );19 );
20 useEffect(() => {
21 setStatus(src && loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading");
22 }, [src]);
2723
28 if (!src) {24 if (!src) {
29 return (25 return (
apps/web/src/components/chat/MessagesTimeline.tsx+11
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type MessageId,3 type MessageId,
4 type ScopedThreadRef,
4 type ServerProviderSkill,5 type ServerProviderSkill,
5 type TurnId,6 type TurnId,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { parseScopedThreadKey } from "@t3tools/client-runtime";
7import {9import {
8 createContext,10 createContext,
9 Fragment,11 Fragment,
...@@ -109,6 +111,7 @@ import {...@@ -109,6 +111,7 @@ import {
109interface TimelineRowSharedState {111interface TimelineRowSharedState {
110 timestampFormat: TimestampFormat;112 timestampFormat: TimestampFormat;
111 routeThreadKey: string;113 routeThreadKey: string;
114 threadRef: ScopedThreadRef | null;
112 markdownCwd: string | undefined;115 markdownCwd: string | undefined;
113 resolvedTheme: "light" | "dark";116 resolvedTheme: "light" | "dark";
114 workspaceRoot: string | undefined;117 workspaceRoot: string | undefined;
...@@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -305,6 +308,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
305 () => ({308 () => ({
306 timestampFormat,309 timestampFormat,
307 routeThreadKey,310 routeThreadKey,
311 threadRef: parseScopedThreadKey(routeThreadKey),
308 markdownCwd,312 markdownCwd,
309 resolvedTheme,313 resolvedTheme,
310 workspaceRoot,314 workspaceRoot,
...@@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -562,6 +566,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
562 <ChatMarkdown566 <ChatMarkdown
563 text={messageText}567 text={messageText}
564 cwd={ctx.markdownCwd}568 cwd={ctx.markdownCwd}
569 threadRef={ctx.threadRef ?? undefined}
565 isStreaming={Boolean(row.message.streaming)}570 isStreaming={Boolean(row.message.streaming)}
566 skills={ctx.skills}571 skills={ctx.skills}
567 />572 />
...@@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({...@@ -625,6 +630,7 @@ function ProposedPlanTimelineRow({
625 <ProposedPlanCard630 <ProposedPlanCard
626 planMarkdown={row.proposedPlan.planMarkdown}631 planMarkdown={row.proposedPlan.planMarkdown}
627 environmentId={ctx.activeThreadEnvironmentId}632 environmentId={ctx.activeThreadEnvironmentId}
633 threadRef={ctx.threadRef ?? undefined}
628 cwd={ctx.markdownCwd}634 cwd={ctx.markdownCwd}
629 workspaceRoot={ctx.workspaceRoot}635 workspaceRoot={ctx.workspaceRoot}
630 />636 />
...@@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1028,6 +1034,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1028 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;1034 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
1029 markdownCwd: string | undefined;1035 markdownCwd: string | undefined;
1030}) {1036}) {
1037 const ctx = use(TimelineRowCtx);
1031 const renderInlineMarkdownSegment = (text: string, key: string) => {1038 const renderInlineMarkdownSegment = (text: string, key: string) => {
1032 const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? "";1039 const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? "";
1033 const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length);1040 const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length);
...@@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1044,6 +1051,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1044 <ChatMarkdown1051 <ChatMarkdown
1045 text={content}1052 text={content}
1046 cwd={props.markdownCwd}1053 cwd={props.markdownCwd}
1054 threadRef={ctx.threadRef ?? undefined}
1047 skills={props.skills}1055 skills={props.skills}
1048 className="text-foreground"1056 className="text-foreground"
1049 lineBreaks1057 lineBreaks
...@@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1065,6 +1073,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1065 <ChatMarkdown1073 <ChatMarkdown
1066 text={segment.text.trim()}1074 text={segment.text.trim()}
1067 cwd={props.markdownCwd}1075 cwd={props.markdownCwd}
1076 threadRef={ctx.threadRef ?? undefined}
1068 skills={props.skills}1077 skills={props.skills}
1069 className="text-foreground"1078 className="text-foreground"
1070 lineBreaks1079 lineBreaks
...@@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1152,6 +1161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1152 key="user-message-terminal-context-inline-text"1161 key="user-message-terminal-context-inline-text"
1153 text={props.text}1162 text={props.text}
1154 cwd={props.markdownCwd}1163 cwd={props.markdownCwd}
1164 threadRef={ctx.threadRef ?? undefined}
1155 skills={props.skills}1165 skills={props.skills}
1156 className="text-foreground"1166 className="text-foreground"
1157 lineBreaks1167 lineBreaks
...@@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -1176,6 +1186,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
1176 <ChatMarkdown1186 <ChatMarkdown
1177 text={props.text}1187 text={props.text}
1178 cwd={props.markdownCwd}1188 cwd={props.markdownCwd}
1189 threadRef={ctx.threadRef ?? undefined}
1179 skills={props.skills}1190 skills={props.skills}
1180 className="text-foreground"1191 className="text-foreground"
1181 lineBreaks1192 lineBreaks
apps/web/src/components/chat/ProposedPlanCard.tsx+15-3
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { memo, useState, useId } from "react";1import { memo, useState, useId } from "react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
3import {3import {
4 buildCollapsedProposedPlanPreviewMarkdown,4 buildCollapsedProposedPlanPreviewMarkdown,
5 buildProposedPlanMarkdownFilename,5 buildProposedPlanMarkdownFilename,
...@@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";...@@ -31,11 +31,13 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
31export const ProposedPlanCard = memo(function ProposedPlanCard({31export const ProposedPlanCard = memo(function ProposedPlanCard({
32 planMarkdown,32 planMarkdown,
33 environmentId,33 environmentId,
34 threadRef,
34 cwd,35 cwd,
35 workspaceRoot,36 workspaceRoot,
36}: {37}: {
37 planMarkdown: string;38 planMarkdown: string;
38 environmentId: EnvironmentId;39 environmentId: EnvironmentId;
40 threadRef?: ScopedThreadRef | undefined;
39 cwd: string | undefined;41 cwd: string | undefined;
40 workspaceRoot: string | undefined;42 workspaceRoot: string | undefined;
41}) {43}) {
...@@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -163,9 +165,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
163 <div className="mt-4">165 <div className="mt-4">
164 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>166 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>
165 {canCollapse && !expanded ? (167 {canCollapse && !expanded ? (
166 <ChatMarkdown text={collapsedPreview ?? ""} cwd={cwd} isStreaming={false} />168 <ChatMarkdown
169 text={collapsedPreview ?? ""}
170 cwd={cwd}
171 threadRef={threadRef}
172 isStreaming={false}
173 />
167 ) : (174 ) : (
168 <ChatMarkdown text={displayedPlanMarkdown} cwd={cwd} isStreaming={false} />175 <ChatMarkdown
176 text={displayedPlanMarkdown}
177 cwd={cwd}
178 threadRef={threadRef}
179 isStreaming={false}
180 />
169 )}181 )}
170 {canCollapse && !expanded ? (182 {canCollapse && !expanded ? (
171 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />183 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />
apps/web/src/components/preview/PreviewChromeRow.browser.tsx+34
...@@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => {...@@ -38,4 +38,38 @@ describe("PreviewChromeRow", () => {
3838
39 previouslyFocused.remove();39 previouslyFocused.remove();
40 });40 });
41
42 it("shows a friendly asset label until the URL input receives focus", async () => {
43 const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf";
44 await render(
45 <PreviewChromeRow
46 {...defaultProps}
47 url={fullUrl}
48 displayUrl="Local environment · report.pdf"
49 />,
50 );
51 const input = page.getByRole("textbox");
52
53 await expect.element(input).toHaveValue("Local environment · report.pdf");
54
55 await input.click();
56
57 await expect.element(input).toHaveValue(fullUrl);
58
59 input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
60
61 await expect.element(input).toHaveValue("Local environment · report.pdf");
62 });
63
64 it("shows only the host for regular URLs until the input receives focus", async () => {
65 const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79";
66 await render(<PreviewChromeRow {...defaultProps} url={fullUrl} displayUrl="t3.chat" />);
67 const input = page.getByRole("textbox");
68
69 await expect.element(input).toHaveValue("t3.chat");
70
71 await input.click();
72
73 await expect.element(input).toHaveValue(fullUrl);
74 });
41});75});
apps/web/src/components/preview/PreviewChromeRow.tsx+23-9
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 ArrowRight,3 ArrowRight,
4 Camera,4 Camera,
5 ExternalLink,5 ExternalLink,
6 Globe,
7 MousePointerClick,6 MousePointerClick,
8 RotateCw,7 RotateCw,
9} from "lucide-react";8} from "lucide-react";
...@@ -23,6 +22,7 @@ import { cn } from "~/lib/utils";...@@ -23,6 +22,7 @@ import { cn } from "~/lib/utils";
2322
24interface Props {23interface Props {
25 url: string;24 url: string;
25 displayUrl?: string | undefined;
26 loading: boolean;26 loading: boolean;
27 loadProgress: number;27 loadProgress: number;
28 canGoBack: boolean;28 canGoBack: boolean;
...@@ -61,6 +61,7 @@ const NOOP = () => {};...@@ -61,6 +61,7 @@ const NOOP = () => {};
6161
62export function PreviewChromeRow({62export function PreviewChromeRow({
63 url,63 url,
64 displayUrl,
64 loading,65 loading,
65 loadProgress,66 loadProgress,
66 canGoBack,67 canGoBack,
...@@ -84,6 +85,7 @@ export function PreviewChromeRow({...@@ -84,6 +85,7 @@ export function PreviewChromeRow({
84}: Props) {85}: Props) {
85 const inputRef = useRef<HTMLInputElement | null>(null);86 const inputRef = useRef<HTMLInputElement | null>(null);
86 const [draft, setDraft] = useState(url);87 const [draft, setDraft] = useState(url);
88 const [inputFocused, setInputFocused] = useState(false);
8789
88 // Sync the input with external URL changes, but only when the user isn't90 // Sync the input with external URL changes, but only when the user isn't
89 // actively typing (preserves in-progress edits during navigation events).91 // actively typing (preserves in-progress edits during navigation events).
...@@ -96,7 +98,6 @@ export function PreviewChromeRow({...@@ -96,7 +98,6 @@ export function PreviewChromeRow({
96 const node = inputRef.current;98 const node = inputRef.current;
97 if (!node) return;99 if (!node) return;
98 node.focus();100 node.focus();
99 node.select();
100 }, [focusUrlNonce]);101 }, [focusUrlNonce]);
101102
102 const submit = (event?: FormEvent | KeyboardEvent) => {103 const submit = (event?: FormEvent | KeyboardEvent) => {
...@@ -167,14 +168,23 @@ export function PreviewChromeRow({...@@ -167,14 +168,23 @@ export function PreviewChromeRow({
167 </Tooltip>168 </Tooltip>
168 </div>169 </div>
169170
170 <InputGroup className="h-7 flex-1 rounded-md">171 <InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background">
171 <InputGroupAddon align="inline-start">
172 <Globe className="size-3.5 text-muted-foreground" aria-hidden />
173 </InputGroupAddon>
174 <InputGroupInput172 <InputGroupInput
175 ref={inputRef}173 ref={inputRef}
176 value={draft}174 value={inputFocused ? draft : (displayUrl ?? draft)}
175 className={cn(
176 onOpenInBrowser && !inputFocused && "group-hover/address:pe-7 transition-[padding]",
177 )}
177 onChange={(event) => setDraft(event.target.value)}178 onChange={(event) => setDraft(event.target.value)}
179 onFocus={() => {
180 setDraft(url);
181 setInputFocused(true);
182 queueMicrotask(() => inputRef.current?.select());
183 }}
184 onBlur={() => {
185 setDraft(url);
186 setInputFocused(false);
187 }}
178 onKeyDown={(event) => {188 onKeyDown={(event) => {
179 if (event.key === "Enter") submit(event);189 if (event.key === "Enter") submit(event);
180 if (event.key === "Escape") {190 if (event.key === "Escape") {
...@@ -187,10 +197,14 @@ export function PreviewChromeRow({...@@ -187,10 +197,14 @@ export function PreviewChromeRow({
187 spellCheck={false}197 spellCheck={false}
188 disabled={inputDisabled}198 disabled={inputDisabled}
189 data-preview-url-input199 data-preview-url-input
200 title={!inputFocused && displayUrl ? url : undefined}
190 size="sm"201 size="sm"
191 />202 />
192 {onOpenInBrowser ? (203 {onOpenInBrowser && !inputFocused ? (
193 <InputGroupAddon align="inline-end">204 <InputGroupAddon
205 align="inline-end"
206 className="pointer-events-none absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover/address:pointer-events-auto group-hover/address:opacity-100"
207 >
194 <Tooltip>208 <Tooltip>
195 <TooltipTrigger209 <TooltipTrigger
196 render={210 render={
apps/web/src/components/preview/PreviewView.tsx+12
...@@ -14,11 +14,13 @@ import {...@@ -14,11 +14,13 @@ import {
14import { ensureLocalApi } from "~/localApi";14import { ensureLocalApi } from "~/localApi";
15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
16import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";16import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
17import { readEnvironmentConnection } from "~/environments/runtime";
1718
18import { previewBridge } from "./previewBridge";19import { previewBridge } from "./previewBridge";
19import { subscribePreviewAction } from "./previewActionBus";20import { subscribePreviewAction } from "./previewActionBus";
20import { openPreviewSession } from "./openPreviewSession";21import { openPreviewSession } from "./openPreviewSession";
21import { PreviewChromeRow } from "./PreviewChromeRow";22import { PreviewChromeRow } from "./PreviewChromeRow";
23import { formatPreviewUrl } from "./previewUrlPresentation";
22import { PreviewEmptyState } from "./PreviewEmptyState";24import { PreviewEmptyState } from "./PreviewEmptyState";
23import { PreviewMoreMenu } from "./PreviewMoreMenu";25import { PreviewMoreMenu } from "./PreviewMoreMenu";
24import { PreviewUnreachable } from "./PreviewUnreachable";26import { PreviewUnreachable } from "./PreviewUnreachable";
...@@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -87,6 +89,15 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
87 const showEmptyState = shouldShowPreviewEmptyState(snapshot);89 const showEmptyState = shouldShowPreviewEmptyState(snapshot);
88 const controller = desktopOverlay?.controller ?? "none";90 const controller = desktopOverlay?.controller ?? "none";
89 const loadProgress = useLoadingProgress(loading);91 const loadProgress = useLoadingProgress(loading);
92 const environmentConnection = readEnvironmentConnection(threadRef.environmentId);
93 const displayUrl =
94 url && environmentConnection
95 ? (formatPreviewUrl({
96 url,
97 environmentLabel: environmentConnection.knownEnvironment.label,
98 environmentHttpBaseUrl: environmentConnection.knownEnvironment.target.httpBaseUrl,
99 }) ?? undefined)
100 : undefined;
90101
91 const handleSubmitUrl = useCallback(102 const handleSubmitUrl = useCallback(
92 async (next: string) => {103 async (next: string) => {
...@@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -501,6 +512,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
501 >512 >
502 <PreviewChromeRow513 <PreviewChromeRow
503 url={url}514 url={url}
515 displayUrl={displayUrl}
504 loading={loading}516 loading={loading}
505 loadProgress={loadProgress}517 loadProgress={loadProgress}
506 canGoBack={canGoBack}518 canGoBack={canGoBack}
apps/web/src/components/preview/previewUrlPresentation.test.ts created+45
...@@ -0,0 +1,45 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { formatPreviewUrl } from "./previewUrlPresentation";
4
5describe("formatPreviewUrl", () => {
6 it("formats signed asset URLs with the environment label and decoded filename", () => {
7 expect(
8 formatPreviewUrl({
9 url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf",
10 environmentLabel: "Local environment",
11 environmentHttpBaseUrl: "http://127.0.0.1:3773",
12 }),
13 ).toBe("Local environment · architecture brief.pdf");
14 });
15
16 it("does not alias assets from another origin", () => {
17 expect(
18 formatPreviewUrl({
19 url: "https://example.com/api/assets/token/report.pdf",
20 environmentLabel: "Local environment",
21 environmentHttpBaseUrl: "http://127.0.0.1:3773",
22 }),
23 ).toBe("example.com");
24 });
25
26 it("formats regular preview URLs as their exact host", () => {
27 expect(
28 formatPreviewUrl({
29 url: "http://127.0.0.1:5173/dashboard",
30 environmentLabel: "Local environment",
31 environmentHttpBaseUrl: "http://127.0.0.1:3773",
32 }),
33 ).toBe("127.0.0.1:5173");
34 });
35
36 it("does not compact non-http URLs", () => {
37 expect(
38 formatPreviewUrl({
39 url: "file:///tmp/report.pdf",
40 environmentLabel: "Local environment",
41 environmentHttpBaseUrl: "http://127.0.0.1:3773",
42 }),
43 ).toBeNull();
44 });
45});
apps/web/src/components/preview/previewUrlPresentation.ts created+27
...@@ -0,0 +1,27 @@
1interface PreviewUrlPresentationInput {
2 readonly url: string;
3 readonly environmentLabel: string;
4 readonly environmentHttpBaseUrl: string;
5}
6
7export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null {
8 try {
9 const url = new URL(input.url);
10 const environmentUrl = new URL(input.environmentHttpBaseUrl);
11 if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) {
12 const encodedFileName = url.pathname.split("/").at(-1);
13 if (!encodedFileName) {
14 return null;
15 }
16 const fileName = decodeURIComponent(encodedFileName);
17 if (!fileName || fileName === "." || fileName === "..") {
18 return null;
19 }
20 return `${input.environmentLabel} · ${fileName}`;
21 }
22
23 return url.protocol === "http:" || url.protocol === "https:" ? url.host : null;
24 } catch {
25 return null;
26 }
27}
apps/web/src/environmentApi.ts+3
...@@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -25,6 +25,9 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
25 filesystem: {25 filesystem: {
26 browse: rpcClient.filesystem.browse,26 browse: rpcClient.filesystem.browse,
27 },27 },
28 assets: {
29 createUrl: rpcClient.assets.createUrl,
30 },
28 sourceControl: {31 sourceControl: {
29 lookupRepository: rpcClient.sourceControl.lookupRepository,32 lookupRepository: rpcClient.sourceControl.lookupRepository,
30 cloneRepository: rpcClient.sourceControl.cloneRepository,33 cloneRepository: rpcClient.sourceControl.cloneRepository,
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+1
...@@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {...@@ -127,6 +127,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
127 filesystem: {127 filesystem: {
128 browse: vi.fn(),128 browse: vi.fn(),
129 },129 },
130 assets: { createUrl: vi.fn() },
130 sourceControl: {131 sourceControl: {
131 lookupRepository: vi.fn(),132 lookupRepository: vi.fn(),
132 cloneRepository: vi.fn(),133 cloneRepository: vi.fn(),
apps/web/src/store.ts-9
...@@ -34,7 +34,6 @@ import {...@@ -34,7 +34,6 @@ import {
34 type ThreadTurnState,34 type ThreadTurnState,
35 type TurnDiffSummary,35 type TurnDiffSummary,
36} from "./types";36} from "./types";
37import { resolveEnvironmentHttpUrl } from "./environments/runtime";
38import { sanitizeThreadErrorMessage } from "./rpc/transportError";37import { sanitizeThreadErrorMessage } from "./rpc/transportError";
39import { getThreadFromEnvironmentState } from "./threadDerivation";38import { getThreadFromEnvironmentState } from "./threadDerivation";
40const isProviderDriverKindValue = Schema.is(ProviderDriverKind);39const isProviderDriverKindValue = Schema.is(ProviderDriverKind);
...@@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage)...@@ -173,10 +172,6 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage)
173 name: attachment.name,172 name: attachment.name,
174 mimeType: attachment.mimeType,173 mimeType: attachment.mimeType,
175 sizeBytes: attachment.sizeBytes,174 sizeBytes: attachment.sizeBytes,
176 previewUrl: resolveEnvironmentHttpUrl({
177 environmentId,
178 pathname: attachmentPreviewRoutePath(attachment.id),
179 }),
180 }));175 }));
181176
182 return {177 return {
...@@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind {...@@ -1042,10 +1037,6 @@ function toLegacyProvider(providerName: string | null): ProviderDriverKind {
1042 return ProviderDriverKind.make("codex");1037 return ProviderDriverKind.make("codex");
1043}1038}
10441039
1045function attachmentPreviewRoutePath(attachmentId: string): string {
1046 return `/attachments/${encodeURIComponent(attachmentId)}`;
1047}
1048
1049function updateThreadState(1040function updateThreadState(
1050 state: EnvironmentState,1041 state: EnvironmentState,
1051 threadId: ThreadId,1042 threadId: ThreadId,
docs/cloud/t3-code-connect-auth-flow-3-page.html created+592
...@@ -0,0 +1,592 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>T3 Connect Architecture Brief</title>
7 <style>
8 :root {
9 --ink: #171717;
10 --muted: #62646b;
11 --line: #d8d8dd;
12 --paper: #f5f5f2;
13 --blue: #1457d9;
14 --green: #1d7d4d;
15 --amber: #a65f00;
16 }
17 * {
18 box-sizing: border-box;
19 }
20 @page {
21 size: Letter;
22 margin: 0;
23 }
24 body {
25 margin: 0;
26 color: var(--ink);
27 background: #ddd;
28 font-family:
29 ui-sans-serif,
30 -apple-system,
31 BlinkMacSystemFont,
32 "Segoe UI",
33 sans-serif;
34 line-height: 1.34;
35 }
36 .page {
37 position: relative;
38 width: 8.5in;
39 height: 11in;
40 margin: 20px auto;
41 padding: 0.47in 0.52in 0.42in;
42 overflow: hidden;
43 background:
44 linear-gradient(90deg, rgba(0, 0, 0, 0.028) 1px, transparent 1px),
45 linear-gradient(rgba(0, 0, 0, 0.028) 1px, transparent 1px), var(--paper);
46 background-size: 25px 25px;
47 break-after: page;
48 }
49 .page:last-child {
50 break-after: auto;
51 }
52 .eyebrow {
53 color: var(--blue);
54 font-size: 9px;
55 font-weight: 800;
56 letter-spacing: 0.1em;
57 text-transform: uppercase;
58 }
59 h1 {
60 max-width: 6.9in;
61 margin: 7px 0 10px;
62 font-size: 35px;
63 line-height: 0.98;
64 }
65 h2 {
66 margin: 0 0 8px;
67 font-size: 19px;
68 line-height: 1.08;
69 }
70 h3 {
71 margin: 0 0 4px;
72 font-size: 12px;
73 }
74 p {
75 margin: 0 0 7px;
76 }
77 ul {
78 margin: 5px 0 0;
79 padding-left: 17px;
80 }
81 li + li {
82 margin-top: 3px;
83 }
84 code {
85 border-radius: 3px;
86 background: #ecece8;
87 padding: 1px 3px;
88 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
89 font-size: 0.9em;
90 }
91 .lede {
92 max-width: 7.1in;
93 color: var(--muted);
94 font-size: 12px;
95 }
96 .rule {
97 height: 2px;
98 margin: 14px 0;
99 background: var(--ink);
100 }
101 .grid {
102 display: grid;
103 grid-template-columns: repeat(12, 1fr);
104 gap: 10px;
105 }
106 .panel {
107 grid-column: span 12;
108 border: 1px solid var(--line);
109 border-radius: 7px;
110 background: rgba(255, 255, 255, 0.94);
111 padding: 12px;
112 font-size: 10px;
113 }
114 .half {
115 grid-column: span 6;
116 }
117 .third {
118 grid-column: span 4;
119 }
120 .callout {
121 margin-top: 8px;
122 border-left: 4px solid var(--blue);
123 background: #eef4ff;
124 padding: 8px 9px;
125 font-size: 10px;
126 }
127 .warning {
128 border-left-color: var(--amber);
129 background: #fff6e6;
130 }
131 .topology {
132 display: grid;
133 grid-template-columns: 1.1fr 34px 1.1fr 34px 1.1fr;
134 align-items: center;
135 gap: 5px;
136 margin-top: 8px;
137 }
138 .node {
139 min-height: 58px;
140 border: 1px solid var(--line);
141 border-top: 4px solid var(--blue);
142 border-radius: 6px;
143 background: white;
144 padding: 8px;
145 text-align: center;
146 font-size: 9px;
147 }
148 .node.green {
149 border-top-color: var(--green);
150 }
151 .arrow {
152 color: var(--muted);
153 text-align: center;
154 font-size: 22px;
155 }
156 .flow {
157 display: grid;
158 grid-template-columns: repeat(4, 1fr);
159 gap: 7px;
160 margin-top: 8px;
161 }
162 .step {
163 position: relative;
164 min-height: 80px;
165 border: 1px solid var(--line);
166 border-left: 4px solid var(--green);
167 border-radius: 6px;
168 background: white;
169 padding: 8px;
170 font-size: 9px;
171 }
172 .step b {
173 display: block;
174 margin-bottom: 3px;
175 font-size: 10px;
176 }
177 table {
178 width: 100%;
179 border-collapse: collapse;
180 font-size: 8.7px;
181 }
182 th,
183 td {
184 border-bottom: 1px solid var(--line);
185 padding: 5px 6px;
186 text-align: left;
187 vertical-align: top;
188 }
189 th {
190 color: var(--muted);
191 font-size: 8px;
192 letter-spacing: 0.05em;
193 text-transform: uppercase;
194 }
195 .footer {
196 position: absolute;
197 right: 0.52in;
198 bottom: 0.18in;
199 color: var(--muted);
200 font-size: 8px;
201 }
202 @media print {
203 body {
204 background: none;
205 }
206 .page {
207 margin: 0;
208 }
209 }
210 </style>
211 </head>
212 <body>
213 <section class="page">
214 <div class="eyebrow">Architecture brief · page 1 of 3</div>
215 <h1>T3 Connect Control Plane and Managed Endpoint Flow</h1>
216 <p class="lede">
217 T3 Connect links a locally authorized T3 environment to a signed-in cloud user, provisions a
218 managed HTTPS/WSS endpoint, and brokers proof-bound remote connections. The environment
219 remains the access authority; the relay coordinates identity, endpoint lifecycle, and
220 notifications.
221 </p>
222 <div class="rule"></div>
223
224 <div class="grid">
225 <article class="panel half">
226 <h2>Security Invariant</h2>
227 <p>Cloud identity alone is not an environment login. Remote access requires:</p>
228 <ul>
229 <li>a Clerk-authenticated T3 Connect user;</li>
230 <li>an active user/environment relay link;</li>
231 <li>a DPoP private key held by the remote client;</li>
232 <li>a relay-signed mint request accepted locally; and</li>
233 <li>an environment token bound to the client's DPoP key.</li>
234 </ul>
235 <div class="callout">
236 The relay cannot mint an environment access token by itself. The local environment
237 issues the bootstrap credential and final access token.
238 </div>
239 </article>
240 <article class="panel half">
241 <h2>Trust Boundaries</h2>
242 <ul>
243 <li><b>Clerk:</b> user identity and OAuth/PKCE.</li>
244 <li><b>Relay Worker:</b> links, endpoint allocations, proofs, and notifications.</li>
245 <li><b>Environment:</b> local sessions, signing keys, and access tokens.</li>
246 <li><b>Relay client:</b> exposes only the validated loopback HTTP server.</li>
247 <li><b>Remote client:</b> retains the DPoP private key and environment token.</li>
248 </ul>
249 <div class="callout warning">
250 The relay remains a trusted mint broker because it holds the cloud-mint signing key.
251 DPoP limits credential reuse but does not neutralize compromise of that authority.
252 </div>
253 </article>
254
255 <article class="panel">
256 <h2>Current Topology</h2>
257 <div class="topology">
258 <div class="node">
259 <h3>Clients</h3>
260 Desktop web UI<br />Mobile app<br />Headless CLI<br /><br />
261 <b>Clerk bearer / relay DPoP</b>
262 </div>
263 <div class="arrow">→</div>
264 <div class="node">
265 <h3>T3 Code Relay</h3>
266 Cloudflare Worker<br />Hyperdrive + Postgres<br />Tunnel/DNS APIs<br />APNs queue
267 </div>
268 <div class="arrow">→</div>
269 <div class="node green">
270 <h3>Linked Environment</h3>
271 Managed HTTPS/WSS endpoint<br />Relay client<br />Loopback server<br />Local secrets
272 </div>
273 </div>
274 <div class="callout">
275 Steady-state HTTP and WebSocket traffic goes directly from the remote client through the
276 managed endpoint. The relay handles sparse health and bootstrap requests, not the active
277 session data path.
278 </div>
279 </article>
280
281 <article class="panel">
282 <h2>Authentication Boundaries</h2>
283 <table>
284 <thead>
285 <tr>
286 <th>Boundary</th>
287 <th>Authentication</th>
288 <th>Purpose</th>
289 </tr>
290 </thead>
291 <tbody>
292 <tr>
293 <td>Client → relay link management</td>
294 <td>Clerk bearer / CLI OAuth</td>
295 <td>Create, list, or remove links.</td>
296 </tr>
297 <tr>
298 <td>Client → protected relay APIs</td>
299 <td>Relay DPoP token + proof</td>
300 <td>Status, connect, and mobile registration.</td>
301 </tr>
302 <tr>
303 <td>Relay → environment</td>
304 <td>Short-lived relay-signed JWT</td>
305 <td>Signed health and credential-mint requests.</td>
306 </tr>
307 <tr>
308 <td>Client → environment</td>
309 <td>Environment token + DPoP</td>
310 <td>Normal HTTPS APIs and WSS ticket issuance.</td>
311 </tr>
312 <tr>
313 <td>Environment → relay</td>
314 <td>Environment bearer + signed proof</td>
315 <td>Publish redacted agent activity.</td>
316 </tr>
317 </tbody>
318 </table>
319 </article>
320 </div>
321 <div class="footer">T3 Connect Architecture Brief · 1</div>
322 </section>
323
324 <section class="page">
325 <div class="eyebrow">Core workflows · page 2 of 3</div>
326 <h1>Link, Bootstrap, and Operate</h1>
327 <p class="lede">
328 Every workflow preserves local authority while making cloud coordination retry-safe and
329 suitable for desktop, mobile, and headless use.
330 </p>
331 <div class="rule"></div>
332
333 <div class="grid">
334 <article class="panel">
335 <h2>1. Link a Local Environment</h2>
336 <div class="flow">
337 <div class="step">
338 <b>Authenticate</b>Desktop/mobile uses a Clerk bearer. CLI uses browser PKCE and
339 persists desired link state.
340 </div>
341 <div class="step">
342 <b>Prove locally</b>Relay issues a short-lived challenge. The local environment
343 validates loopback origin and signs it.
344 </div>
345 <div class="step">
346 <b>Reconcile</b>Relay verifies proof and capabilities, then creates or reuses the
347 tunnel, ingress, and CNAME.
348 </div>
349 <div class="step">
350 <b>Activate</b>Relay returns endpoint config and connector token. Environment persists
351 config and starts the relay client.
352 </div>
353 </div>
354 <div class="callout">
355 The local proof endpoint requires <code>relay:write</code>, rejects forwarded authority
356 headers, and atomically persists the environment key pair.
357 </div>
358 </article>
359
360 <article class="panel">
361 <h2>2. Remote Connection Bootstrap</h2>
362 <div class="flow">
363 <div class="step">
364 <b>Proof key</b>Client creates a DPoP key and exchanges its Clerk JWT for a relay DPoP
365 access token.
366 </div>
367 <div class="step">
368 <b>Mint request</b>Client calls connect. Relay sends an audience-bound, short-lived
369 signed proof to the managed endpoint.
370 </div>
371 <div class="step">
372 <b>Local issue</b>Environment validates user, scope, lifetime, nonce, and DPoP
373 thumbprint, then returns a signed bootstrap credential.
374 </div>
375 <div class="step">
376 <b>Direct session</b>Client redeems locally for an environment token, gets a one-time
377 WSS ticket, and opens the WebSocket.
378 </div>
379 </div>
380 <div class="callout">
381 The relay validates the environment response signature, nonce, endpoint, and DPoP
382 binding before returning bootstrap data to the client.
383 </div>
384 </article>
385
386 <article class="panel half">
387 <h2>Managed Endpoint Lifecycle</h2>
388 <ol>
389 <li>
390 Reserve deterministic hostname and tunnel name for
391 <code>(userId, environmentId)</code>.
392 </li>
393 <li>
394 Reuse or create the named tunnel; rewrite ingress to the validated loopback origin.
395 </li>
396 <li>
397 Reconcile CNAME records and remove duplicates; recover safely from create conflicts.
398 </li>
399 <li>Mark ready only after resource IDs and namespace checks are persisted.</li>
400 </ol>
401 </article>
402 <article class="panel half">
403 <h2>Unlink Cleanup</h2>
404 <ul>
405 <li>Delete managed DNS and tunnel resources.</li>
406 <li>Remove the allocation and revoke the user link.</li>
407 <li>Revoke publication credentials after the final active link disappears.</li>
408 <li>Stop the local relay client and clear persisted relay configuration.</li>
409 <li>Retain checkpoints when teardown fails so cleanup can be retried.</li>
410 </ul>
411 </article>
412
413 <article class="panel">
414 <h2>3. Agent Activity Notifications</h2>
415 <div class="flow">
416 <div class="step">
417 <b>Project</b>Environment creates a narrow publishable thread state and redacts/caps
418 failure details.
419 </div>
420 <div class="step">
421 <b>Sign</b>Environment posts with its bearer credential and a per-state signed proof.
422 </div>
423 <div class="step">
424 <b>Persist</b>Relay checks credential, signature, scope, expiry, and nonce, then
425 stores current state.
426 </div>
427 <div class="step">
428 <b>Deliver</b>Queue workers send push notifications or Live Activity updates through
429 APNs.
430 </div>
431 </div>
432 </article>
433 </div>
434 <div class="footer">T3 Connect Architecture Brief · 2</div>
435 </section>
436
437 <section class="page">
438 <div class="eyebrow">Controls and operations · page 3 of 3</div>
439 <h1>Hardening and Deployment Contract</h1>
440 <p class="lede">
441 The design constrains tunnel origins, proof audiences, egress destinations, redirects,
442 credential exposure, and teardown behavior.
443 </p>
444 <div class="rule"></div>
445
446 <div class="grid">
447 <article class="panel">
448 <h2>SSRF and Tunnel Controls</h2>
449 <table>
450 <thead>
451 <tr>
452 <th>Risk</th>
453 <th>Implemented control</th>
454 </tr>
455 </thead>
456 <tbody>
457 <tr>
458 <td>Arbitrary tunnel upstream</td>
459 <td>Both sides accept managed origins only for loopback hosts and valid ports.</td>
460 </tr>
461 <tr>
462 <td>Forwarded-origin confusion</td>
463 <td>
464 Link proof requires the exact loopback URL and rejects forwarded host/protocol
465 headers.
466 </td>
467 </tr>
468 <tr>
469 <td>Stored endpoint replacement</td>
470 <td>
471 Status/connect resolve ready allocations from Postgres and require the managed DNS
472 namespace.
473 </td>
474 </tr>
475 <tr>
476 <td>Redirected relay egress</td>
477 <td>Relay health and mint requests use manual redirect handling.</td>
478 </tr>
479 <tr>
480 <td>Impersonated tunnel backend</td>
481 <td>Relay verifies signed environment responses, nonce, and request binding.</td>
482 </tr>
483 <tr>
484 <td>Stolen access token replay</td>
485 <td>Relay and environment tokens require DPoP proofs and replay guards.</td>
486 </tr>
487 <tr>
488 <td>Connector token exposure</td>
489 <td>
490 Relay client is spawned without a shell and receives the token in
491 <code>TUNNEL_TOKEN</code>.
492 </td>
493 </tr>
494 </tbody>
495 </table>
496 </article>
497
498 <article class="panel half">
499 <h2>Credential Ownership</h2>
500 <ul>
501 <li>Environment Ed25519 private key stays local.</li>
502 <li>
503 Cloud-mint private key stays in hosted relay config; only its public key is installed
504 locally.
505 </li>
506 <li>
507 Relay environment bearer is hashed in Postgres and stored plaintext only locally.
508 </li>
509 <li>Remote DPoP private keys and environment access tokens remain client-side.</li>
510 <li>WebSocket tickets are short-lived and one-time.</li>
511 </ul>
512 </article>
513 <article class="panel half">
514 <h2>Operational Profile</h2>
515 <ul>
516 <li>One named tunnel and DNS record per active user/environment allocation.</li>
517 <li>HTTPS request/response and WSS only; no arbitrary TCP exposure.</li>
518 <li>Relay traffic is limited to health and bootstrap control calls.</li>
519 <li>
520 Active sessions are interactive and WebSocket-heavy; inactive links are nearly idle.
521 </li>
522 <li>
523 Dedicated registrable tunnel domain and Public Suffix List entry are required before
524 broad untrusted use.
525 </li>
526 </ul>
527 </article>
528
529 <article class="panel">
530 <h2>Deployment and Runtime</h2>
531 <table>
532 <thead>
533 <tr>
534 <th>Layer</th>
535 <th>Contract</th>
536 </tr>
537 </thead>
538 <tbody>
539 <tr>
540 <td>Relay</td>
541 <td>
542 Cloudflare Worker, tunnel/DNS bindings, Hyperdrive, PlanetScale Postgres, APNs
543 queue + DLQ, replay-row pruning cron, and Axiom OTLP traces.
544 </td>
545 </tr>
546 <tr>
547 <td>Environment</td>
548 <td>
549 Loopback server, atomic secret store, relay-client lifecycle, signed health/mint
550 responses, OAuth exchange, and WebSocket tickets.
551 </td>
552 </tr>
553 <tr>
554 <td>Clients</td>
555 <td>
556 Secure relay URL, Clerk configuration, DPoP key management, environment token
557 storage, and direct managed-endpoint traffic.
558 </td>
559 </tr>
560 </tbody>
561 </table>
562 </article>
563
564 <article class="panel">
565 <h2>Release Gate</h2>
566 <div class="grid">
567 <div class="third">
568 <h3>Identity</h3>
569 Keep private signing keys in their owning trust domain. Validate issuer, audience,
570 scope, expiry, nonce, and proof-key binding.
571 </div>
572 <div class="third">
573 <h3>Network</h3>
574 Permit loopback tunnel origins only. Resolve relay egress from ready allocations and
575 disable redirects.
576 </div>
577 <div class="third">
578 <h3>Lifecycle</h3>
579 Make provisioning idempotent, preserve failed-cleanup checkpoints, and revoke shared
580 credentials only after the final link disappears.
581 </div>
582 </div>
583 <div class="callout warning">
584 Standards basis: RFC 8252 for native-app OAuth, RFC 8693 for token exchange, and RFC
585 9449 for DPoP proof of possession.
586 </div>
587 </article>
588 </div>
589 <div class="footer">T3 Connect Architecture Brief · 3</div>
590 </section>
591 </body>
592</html>
docs/cloud/t3-code-connect-auth-flow.pdf created
Binary files /dev/null and b/docs/cloud/t3-code-connect-auth-flow.pdf differ
packages/client-runtime/src/wsRpcClient.ts+7
...@@ -102,6 +102,9 @@ export interface WsRpcClient {...@@ -102,6 +102,9 @@ export interface WsRpcClient {
102 readonly filesystem: {102 readonly filesystem: {
103 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;103 readonly browse: RpcUnaryMethod<typeof WS_METHODS.filesystemBrowse>;
104 };104 };
105 readonly assets: {
106 readonly createUrl: RpcUnaryMethod<typeof WS_METHODS.assetsCreateUrl>;
107 };
105 readonly sourceControl: {108 readonly sourceControl: {
106 readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>;109 readonly lookupRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlLookupRepository>;
107 readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>;110 readonly cloneRepository: RpcUnaryMethod<typeof WS_METHODS.sourceControlCloneRepository>;
...@@ -272,6 +275,10 @@ export function createWsRpcClient(...@@ -272,6 +275,10 @@ export function createWsRpcClient(
272 filesystem: {275 filesystem: {
273 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),276 browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)),
274 },277 },
278 assets: {
279 createUrl: (input) =>
280 transport.request((client) => client[WS_METHODS.assetsCreateUrl](input)),
281 },
275 sourceControl: {282 sourceControl: {
276 lookupRepository: (input) =>283 lookupRepository: (input) =>
277 transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),284 transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)),
packages/contracts/src/assets.ts created+38
...@@ -0,0 +1,38 @@
1import * as Schema from "effect/Schema";
2
3import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5const ASSET_PATH_MAX_LENGTH = 1024;
6
7export const AssetResource = Schema.Union([
8 Schema.TaggedStruct("workspace-file", {
9 threadId: ThreadId,
10 path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
11 }),
12 Schema.TaggedStruct("attachment", {
13 attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
14 }),
15 Schema.TaggedStruct("project-favicon", {
16 cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)),
17 }),
18]);
19export type AssetResource = typeof AssetResource.Type;
20
21export const AssetCreateUrlInput = Schema.Struct({
22 resource: AssetResource,
23});
24export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type;
25
26export const AssetCreateUrlResult = Schema.Struct({
27 relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
28 expiresAt: Schema.Number,
29});
30export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type;
31
32export class AssetAccessError extends Schema.TaggedErrorClass<AssetAccessError>()(
33 "AssetAccessError",
34 {
35 message: TrimmedNonEmptyString,
36 cause: Schema.optional(Schema.Defect()),
37 },
38) {}
packages/contracts/src/index.ts+1
...@@ -21,6 +21,7 @@ export * from "./orchestration.ts";...@@ -21,6 +21,7 @@ export * from "./orchestration.ts";
21export * from "./editor.ts";21export * from "./editor.ts";
22export * from "./project.ts";22export * from "./project.ts";
23export * from "./filesystem.ts";23export * from "./filesystem.ts";
24export * from "./assets.ts";
24export * from "./review.ts";25export * from "./review.ts";
25export * from "./preview.ts";26export * from "./preview.ts";
26export * from "./previewAutomation.ts";27export * from "./previewAutomation.ts";
packages/contracts/src/ipc.ts+4
...@@ -20,6 +20,7 @@ import type {...@@ -20,6 +20,7 @@ import type {
20} from "./git.ts";20} from "./git.ts";
21import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";21import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
22import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";22import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
23import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
23import type {24import type {
24 ProjectSearchEntriesInput,25 ProjectSearchEntriesInput,
25 ProjectSearchEntriesResult,26 ProjectSearchEntriesResult,
...@@ -1115,6 +1116,9 @@ export interface EnvironmentApi {...@@ -1115,6 +1116,9 @@ export interface EnvironmentApi {
1115 filesystem: {1116 filesystem: {
1116 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;1117 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
1117 };1118 };
1119 assets: {
1120 createUrl: (input: AssetCreateUrlInput) => Promise<AssetCreateUrlResult>;
1121 };
1118 sourceControl: {1122 sourceControl: {
1119 lookupRepository: (1123 lookupRepository: (
1120 input: SourceControlRepositoryLookupInput,1124 input: SourceControlRepositoryLookupInput,
packages/contracts/src/rpc.ts+9
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 FilesystemBrowseResult,13 FilesystemBrowseResult,
14 FilesystemBrowseError,14 FilesystemBrowseError,
15} from "./filesystem.ts";15} from "./filesystem.ts";
16import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
16import {17import {
17 GitActionProgressEvent,18 GitActionProgressEvent,
18 VcsSwitchRefInput,19 VcsSwitchRefInput,
...@@ -148,6 +149,7 @@ export const WS_METHODS = {...@@ -148,6 +149,7 @@ export const WS_METHODS = {
148149
149 // Filesystem methods150 // Filesystem methods
150 filesystemBrowse: "filesystem.browse",151 filesystemBrowse: "filesystem.browse",
152 assetsCreateUrl: "assets.createUrl",
151153
152 // VCS methods154 // VCS methods
153 vcsPull: "vcs.pull",155 vcsPull: "vcs.pull",
...@@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {...@@ -365,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
365 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),367 error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]),
366});368});
367369
370export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, {
371 payload: AssetCreateUrlInput,
372 success: AssetCreateUrlResult,
373 error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]),
374});
375
368export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {376export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
369 payload: VcsStatusInput,377 payload: VcsStatusInput,
370 success: VcsStatusStreamEvent,378 success: VcsStatusStreamEvent,
...@@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -672,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make(
672 WsProjectsWriteFileRpc,680 WsProjectsWriteFileRpc,
673 WsShellOpenInEditorRpc,681 WsShellOpenInEditorRpc,
674 WsFilesystemBrowseRpc,682 WsFilesystemBrowseRpc,
683 WsAssetsCreateUrlRpc,
675 WsSubscribeVcsStatusRpc,684 WsSubscribeVcsStatusRpc,
676 WsVcsPullRpc,685 WsVcsPullRpc,
677 WsVcsRefreshStatusRpc,686 WsVcsRefreshStatusRpc,