authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-07 23:52:28-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 23:52:28-03:00
loga01b227d6f37d1cfed7a2f47aaace2f72ea76ce3
tree9d61ff67b5206779d17dc124faab2c5714798dd8
parent9fe4d6568b5e4852f6085d3a22dbc5aa3a632408
signature Signed by PGP key B5690EEEBB952194

fix(desktop): resolve local media linked from remote threads (#10619)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

6 files changed, 248 insertions(+), 11 deletions(-)

apps/server/src/server.test.ts+37
...@@ -5473,6 +5473,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5473,6 +5473,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5473 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5473 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5474 );5474 );
54755475
5476 it.effect("serves absolute host media without a local thread and rejects relative media", () =>
5477 Effect.gen(function* () {
5478 yield* buildAppUnderTest();
5479 const fileSystem = yield* FileSystem.FileSystem;
5480 const path = yield* Path.Path;
5481 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-host-media-" });
5482 const wsUrl = yield* getWsServerUrl("/ws");
5483 const threadId = ThreadId.make("thread-on-another-environment");
5484
5485 yield* Effect.scoped(
5486 withWsRpcClient(wsUrl, (client) =>
5487 Effect.gen(function* () {
5488 for (const [name, mimeType] of [
5489 ["screenshot.png", "image/png"],
5490 ["recording.mp4", "video/mp4"],
5491 ] as const) {
5492 const filePath = path.join(directory, name);
5493 yield* fileSystem.writeFileString(filePath, "host media bytes");
5494 const issued = yield* client[WS_METHODS.assetsCreateUrl]({
5495 resource: { _tag: "media-file", threadId, path: filePath },
5496 });
5497 const response = yield* HttpClient.get(issued.relativeUrl);
5498 assert.equal(response.status, 200);
5499 assert.equal(response.headers["content-type"], mimeType);
5500 assert.equal(yield* response.text, "host media bytes");
5501
5502 const error = yield* client[WS_METHODS.assetsCreateUrl]({
5503 resource: { _tag: "media-file", threadId, path: name },
5504 }).pipe(Effect.flip);
5505 assert.equal(error._tag, "AssetWorkspaceContextNotFoundError");
5506 }
5507 }),
5508 ),
5509 );
5510 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5511 );
5512
5476 it.effect("uploads image bytes through a signed URL issued by websocket rpc", () =>5513 it.effect("uploads image bytes through a signed URL issued by websocket rpc", () =>
5477 Effect.gen(function* () {5514 Effect.gen(function* () {
5478 const config = yield* buildAppUnderTest();5515 const config = yield* buildAppUnderTest();
apps/server/src/ws.ts+5-1
...@@ -9,6 +9,7 @@ import * as Duration from "effect/Duration";...@@ -9,6 +9,7 @@ import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
12import * as Queue from "effect/Queue";13import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";14import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
...@@ -2390,9 +2391,12 @@ const makeWsRpcLayer = (...@@ -2390,9 +2391,12 @@ const makeWsRpcLayer = (
2390 observeRpcEffect(2391 observeRpcEffect(
2391 WS_METHODS.assetsCreateUrl,2392 WS_METHODS.assetsCreateUrl,
2392 Effect.gen(function* () {2393 Effect.gen(function* () {
2394 const path = yield* Path.Path;
2395 // An absolute media path can be linked from a thread on another environment.
2393 if (2396 if (
2394 input.resource._tag === "attachment" ||2397 input.resource._tag === "attachment" ||
2395 input.resource._tag === "native-app-icon"2398 input.resource._tag === "native-app-icon" ||
2399 (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path))
2396 ) {2400 ) {
2397 return yield* issueAssetUrl({ resource: input.resource });2401 return yield* issueAssetUrl({ resource: input.resource });
2398 }2402 }
apps/web/src/state/assets.ts+19-1
...@@ -2,12 +2,30 @@ import {...@@ -2,12 +2,30 @@ import {
2 createAssetEnvironmentAtoms,2 createAssetEnvironmentAtoms,
3 createProjectFaviconUrlAtomFamily,3 createProjectFaviconUrlAtomFamily,
4} from "@t3tools/client-runtime/state/assets";4} from "@t3tools/client-runtime/state/assets";
5import { Atom } from "effect/unstable/reactivity";
56
6import { connectionAtomRuntime } from "../connection/runtime";7import { connectionAtomRuntime } from "../connection/runtime";
7import { projectFaviconCache } from "../assets/projectFaviconCache";8import { projectFaviconCache } from "../assets/projectFaviconCache";
9import { isElectron } from "../env";
10import { primaryEnvironmentIdAtom } from "./primaryEnvironment";
8import { environmentSession } from "./session";11import { environmentSession } from "./session";
912
10export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);13const localMediaEnvironment = Atom.make((get) => {
14 if (!isElectron) return null;
15 const environmentId = get(primaryEnvironmentIdAtom);
16 if (environmentId === null) return null;
17 const connection = get(environmentSession.preparedConnectionValueAtom(environmentId));
18 // The session's bootstrap config clears on disconnect and refreshes on reconnect.
19 const config = get(environmentSession.initialConfigValueAtom(environmentId));
20 return connection._tag === "None" || config === null
21 ? null
22 : { environmentId, httpBaseUrl: connection.value.httpBaseUrl };
23});
24
25export const assetEnvironment = createAssetEnvironmentAtoms(
26 connectionAtomRuntime,
27 localMediaEnvironment,
28);
1129
12export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({30export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
13 imageCache: projectFaviconCache,31 imageCache: projectFaviconCache,
packages/client-runtime/src/state/assets.test.ts+133-2
...@@ -1,11 +1,32 @@...@@ -1,11 +1,32 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";2import {
3 type AssetCreateUrlResult,
4 AssetWorkspaceAssetNotFoundError,
5 AssetWorkspaceAssetInspectionError,
6 AssetWorkspaceContextNotFoundError,
7 EnvironmentAuthorizationError,
8 EnvironmentId,
9 ThreadId,
10 WS_METHODS,
11} from "@t3tools/contracts";
3import * as Cause from "effect/Cause";12import * as Cause from "effect/Cause";
13import * as Effect from "effect/Effect";
14import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";
4import * as Option from "effect/Option";16import * as Option from "effect/Option";
5import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
6import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";18import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
719
8import type { EnvironmentRegistry } from "../connection/registry.ts";20import { EnvironmentRegistry } from "../connection/registry.ts";
21import {
22 AVAILABLE_CONNECTION_STATE,
23 PrimaryConnectionTarget,
24 type PreparedConnection,
25 type SupervisorConnectionState,
26} from "../connection/model.ts";
27import { EnvironmentSupervisor } from "../connection/supervisor.ts";
28import type { RpcSession } from "../rpc/session.ts";
29import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
9import { createProjectFaviconCache } from "../projectFaviconCache.ts";30import { createProjectFaviconCache } from "../projectFaviconCache.ts";
10import {31import {
11 createAssetEnvironmentAtoms,32 createAssetEnvironmentAtoms,
...@@ -37,6 +58,116 @@ describe("asset collection keys", () => {...@@ -37,6 +58,116 @@ describe("asset collection keys", () => {
37});58});
3859
39describe("createAssetEnvironmentAtoms", () => {60describe("createAssetEnvironmentAtoms", () => {
61 for (const scenario of [
62 { name: "missing video", path: "/tmp/clip.mp4", fallback: true },
63 { name: "literal filename characters", path: "/tmp/frame#one?two.png", fallback: true },
64 { name: "windows path", path: "C:\\Users\\demo\\clip.mp4", fallback: true },
65 { name: "inspection failure", path: "/tmp/clip.mp4", error: "inspection", fallback: true },
66 { name: "foreign thread", path: "/tmp/clip.mp4", error: "context", fallback: true },
67 { name: "remote success", path: "/tmp/clip.mp4", success: true },
68 { name: "relative path", path: "clip.mp4" },
69 { name: "no primary", path: "/tmp/clip.mp4", primary: "none" },
70 { name: "primary reconnect", path: "/tmp/frame.png", primary: "reconnecting" },
71 { name: "same environment", path: "/tmp/clip.mp4", primary: "same" },
72 { name: "non-media", path: "/tmp/report.html" },
73 { name: "authorization failure", path: "/tmp/clip.mp4", error: "auth" },
74 ]) {
75 it.effect(`uses the correct environment for ${scenario.name}`, () =>
76 Effect.gen(function* () {
77 const remoteId = EnvironmentId.make("remote");
78 const localId = EnvironmentId.make("local");
79 const resource = {
80 _tag: "media-file" as const,
81 threadId: ThreadId.make("foreign-thread"),
82 path: scenario.path,
83 };
84 const error =
85 scenario.error === "auth"
86 ? new EnvironmentAuthorizationError({
87 message: "denied",
88 requiredScope: "orchestration:read",
89 })
90 : scenario.error === "inspection"
91 ? new AssetWorkspaceAssetInspectionError({ resource, cause: new Error("unreadable") })
92 : scenario.error === "context"
93 ? new AssetWorkspaceContextNotFoundError({ resource })
94 : new AssetWorkspaceAssetNotFoundError({ resource });
95 const calls: EnvironmentId[] = [];
96 const supervisors = new Map<EnvironmentId, EnvironmentSupervisor["Service"]>();
97 for (const environmentId of [remoteId, localId]) {
98 const client = {
99 [WS_METHODS.assetsCreateUrl]: () => {
100 calls.push(environmentId);
101 return environmentId === remoteId && !scenario.success
102 ? Effect.fail(error)
103 : Effect.succeed({
104 relativeUrl: `/api/assets/${environmentId}/media`,
105 expiresAt: 999999,
106 });
107 },
108 } as unknown as WsRpcProtocolClient;
109 const session = { client } as RpcSession;
110 supervisors.set(
111 environmentId,
112 EnvironmentSupervisor.of({
113 target: new PrimaryConnectionTarget({
114 environmentId,
115 label: environmentId,
116 httpBaseUrl: `https://${environmentId}.test`,
117 wsBaseUrl: `wss://${environmentId}.test`,
118 }),
119 state: yield* SubscriptionRef.make<SupervisorConnectionState>({
120 ...AVAILABLE_CONNECTION_STATE,
121 phase: "connected" as const,
122 }),
123 session: yield* SubscriptionRef.make(Option.some(session)),
124 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
125 connect: Effect.void,
126 disconnect: Effect.void,
127 retryNow: Effect.void,
128 }),
129 );
130 }
131 const environments = EnvironmentRegistry.of({
132 run: (id, effect) =>
133 Effect.provideService(effect, EnvironmentSupervisor, supervisors.get(id)!),
134 followStream: (id, stream) =>
135 Stream.provideService(stream, EnvironmentSupervisor, supervisors.get(id)!),
136 } as EnvironmentRegistry["Service"]);
137 const registry = AtomRegistry.make();
138 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
139 const localTarget = {
140 environmentId: scenario.primary === "same" ? remoteId : localId,
141 httpBaseUrl: "https://local.test",
142 };
143 const localEnvironment = Atom.make<typeof localTarget | null>(
144 scenario.primary === "none" || scenario.primary === "reconnecting" ? null : localTarget,
145 );
146 const assets = createAssetEnvironmentAtoms(
147 Atom.runtime(Layer.succeed(EnvironmentRegistry, environments)),
148 localEnvironment,
149 );
150 const query = assets.createUrl({ environmentId: remoteId, input: { resource } });
151 const result = AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });
152 if (scenario.fallback || scenario.success) {
153 expect((yield* result).relativeUrl).toBe(
154 scenario.fallback
155 ? "https://local.test/api/assets/local/media"
156 : "/api/assets/remote/media",
157 );
158 } else {
159 expect(yield* Effect.flip(result)).toEqual(error);
160 }
161 expect(calls).toEqual(scenario.fallback ? [remoteId, localId] : [remoteId]);
162 if (scenario.primary === "reconnecting") {
163 registry.set(localEnvironment, localTarget);
164 expect((yield* result).relativeUrl).toBe("https://local.test/api/assets/local/media");
165 expect(calls).toEqual([remoteId, remoteId, localId]);
166 }
167 }).pipe(Effect.scoped),
168 );
169 }
170
40 it("keys asset URL queries by environment and resource", () => {171 it("keys asset URL queries by environment and resource", () => {
41 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<172 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
42 EnvironmentRegistry,173 EnvironmentRegistry,
packages/client-runtime/src/state/assets.ts+48-6
...@@ -1,22 +1,28 @@...@@ -1,22 +1,28 @@
1import {1import {
2 type AssetCreateUrlInput,
2 type AssetCreateUrlResult,3 type AssetCreateUrlResult,
3 type AssetImageDimensions,4 type AssetImageDimensions,
4 AssetResource,5 AssetResource,
5 EnvironmentId,6 EnvironmentId,
6 WS_METHODS,7 WS_METHODS,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
10import { isWindowsAbsolutePath } from "@t3tools/shared/path";
8import {11import {
9 getProjectFaviconResourceKey,12 getProjectFaviconResourceKey,
10 isProjectFaviconFallbackUrl,13 isProjectFaviconFallbackUrl,
11} from "@t3tools/shared/projectFavicon";14} from "@t3tools/shared/projectFavicon";
12import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";19import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
1620
17import type { EnvironmentRegistry } from "../connection/registry.ts";21import * as EnvironmentRegistry from "../connection/registry.ts";
22import * as EnvironmentSupervisor from "../connection/supervisor.ts";
23import { request } from "../rpc/client.ts";
18import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";24import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts";
19import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";25import { createEnvironmentQueryAtomFamily } from "./runtime.ts";
2026
21const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;27const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000;
22const ASSET_URL_STALE_TIME_MS = 5 * 60_000;28const ASSET_URL_STALE_TIME_MS = 5 * 60_000;
...@@ -91,14 +97,50 @@ export function assetUrlStateFromResult(...@@ -91,14 +97,50 @@ export function assetUrlStateFromResult(
91}97}
9298
93export function createAssetEnvironmentAtoms<R, E>(99export function createAssetEnvironmentAtoms<R, E>(
94 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,100 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
101 localMediaEnvironment?: Atom.Atom<{
102 readonly environmentId: EnvironmentId;
103 readonly httpBaseUrl: string;
104 } | null>,
95) {105) {
96 const createUrl = createEnvironmentRpcQueryAtomFamily(runtime, {106 const execute = Effect.fn("assets.createUrl")(function* (input: AssetCreateUrlInput) {
107 const result = yield* request(WS_METHODS.assetsCreateUrl, input).pipe(Effect.result);
108 if (Result.isSuccess(result)) return result.success;
109 const error = result.failure;
110 const resource = input.resource;
111 const local = localMediaEnvironment
112 ? (yield* AtomRegistry.AtomRegistry).get(localMediaEnvironment)
113 : null;
114 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
115 if (
116 !local ||
117 local.environmentId === supervisor.target.environmentId ||
118 !(
119 error._tag === "AssetWorkspaceAssetNotFoundError" ||
120 error._tag === "AssetWorkspaceAssetInspectionError" ||
121 error._tag === "AssetWorkspaceContextNotFoundError"
122 ) ||
123 resource._tag !== "media-file" ||
124 !(resource.path.startsWith("/") || isWindowsAbsolutePath(resource.path)) ||
125 mediaMimeTypeFromExtension(resource.path.slice(resource.path.lastIndexOf("."))) === null
126 )
127 return yield* error;
128 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
129 const asset = yield* registry.run(
130 local.environmentId,
131 request(WS_METHODS.assetsCreateUrl, input),
132 );
133 // Callers resolve against the thread's server, so preserve the local server's origin.
134 return { ...asset, relativeUrl: new URL(asset.relativeUrl, local.httpBaseUrl).href };
135 });
136 const createUrl = createEnvironmentQueryAtomFamily(runtime, {
97 label: "environment-data:assets:create-url",137 label: "environment-data:assets:create-url",
98 tag: WS_METHODS.assetsCreateUrl,138 execute,
99 staleTimeMs: ASSET_URL_STALE_TIME_MS,139 staleTimeMs: ASSET_URL_STALE_TIME_MS,
100 idleTtlMs: ASSET_URL_IDLE_TTL_MS,140 idleTtlMs: ASSET_URL_IDLE_TTL_MS,
101 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,141 refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS,
142 refreshTrigger: ({ input }) =>
143 input.resource._tag === "media-file" ? localMediaEnvironment : undefined,
102 });144 });
103 const createUrlsFamily = Atom.family((key: string) => {145 const createUrlsFamily = Atom.family((key: string) => {
104 const [environmentId, resources] = parseAssetCollectionKey(key);146 const [environmentId, resources] = parseAssetCollectionKey(key);
packages/client-runtime/src/state/runtime.ts+6-1
...@@ -482,7 +482,12 @@ export function followStreamInEnvironment<A, E, R>(...@@ -482,7 +482,12 @@ export function followStreamInEnvironment<A, E, R>(
482482
483export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(483export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
484 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>,484 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, ER>,
485 options: EnvironmentQueryAtomOptions<Input, A, E, EnvironmentSupervisor | R>,485 options: EnvironmentQueryAtomOptions<
486 Input,
487 A,
488 E,
489 EnvironmentSupervisor | EnvironmentRegistry | AtomRegistry.AtomRegistry | R
490 >,
486): (target: {491): (target: {
487 readonly environmentId: EnvironmentIdType;492 readonly environmentId: EnvironmentIdType;
488 readonly input: Input;493 readonly input: Input;