Skip to content

Commit 753bc46

Browse files
Harden preview ownership and option-based secret handling (#3172)
1 parent e29ad76 commit 753bc46

13 files changed

Lines changed: 690 additions & 405 deletions

apps/server/src/mcp/McpHttpServer.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,15 @@ it.effect("registers annotated tools and preserves authenticated request context
107107
Effect.gen(function* () {
108108
const server = yield* McpServer.McpServer;
109109
const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker;
110-
const requests = yield* broker.connect("mcp-test-client");
110+
const requests = yield* broker.connect({
111+
clientId: "mcp-test-client",
112+
environmentId,
113+
threadId,
114+
tabId,
115+
visible: true,
116+
supportsAutomation: true,
117+
focusedAt: "2026-06-11T00:00:00.000Z",
118+
});
111119
yield* Stream.runForEach(requests, (request) =>
112120
broker.respond({
113121
requestId: request.requestId,

apps/server/src/mcp/PreviewAutomationBroker.test.ts

Lines changed: 122 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { expect, it } from "@effect/vitest";
22
import {
33
EnvironmentId,
44
PreviewAutomationNoFocusedOwnerError,
5+
PreviewAutomationUnavailableError,
56
ProviderInstanceId,
67
ThreadId,
8+
type PreviewAutomationOwner,
79
} from "@t3tools/contracts";
810
import * as Effect from "effect/Effect";
11+
import * as Fiber from "effect/Fiber";
912
import * as Stream from "effect/Stream";
1013

1114
import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
@@ -20,11 +23,22 @@ const scope = {
2023
expiresAt: 2,
2124
};
2225

23-
it.effect("routes a request to the focused owner and correlates its response", () =>
26+
const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAutomationOwner => ({
27+
clientId: "client-1",
28+
environmentId: scope.environmentId,
29+
threadId: scope.threadId,
30+
tabId: null,
31+
visible: false,
32+
supportsAutomation: true,
33+
focusedAt: "2026-06-11T00:00:00.000Z",
34+
...overrides,
35+
});
36+
37+
it.effect("atomically registers a connected owner and correlates its response", () =>
2438
Effect.scoped(
2539
Effect.gen(function* () {
2640
const broker = yield* PreviewAutomationBroker.__testing.make;
27-
const requests = yield* broker.connect("client-1");
41+
const requests = yield* broker.connect(makeOwner());
2842
yield* Stream.runForEach(requests, (request) =>
2943
broker.respond({
3044
requestId: request.requestId,
@@ -33,15 +47,6 @@ it.effect("routes a request to the focused owner and correlates its response", (
3347
}),
3448
).pipe(Effect.forkScoped);
3549
yield* Effect.yieldNow;
36-
yield* broker.reportOwner({
37-
clientId: "client-1",
38-
environmentId: scope.environmentId,
39-
threadId: scope.threadId,
40-
tabId: null,
41-
visible: false,
42-
supportsAutomation: true,
43-
focusedAt: "2026-06-11T00:00:00.000Z",
44-
});
4550

4651
const result = yield* broker.invoke<{ available: boolean }>({
4752
scope,
@@ -68,22 +73,119 @@ it.effect("routes interactive commands to a hidden durable browser host", () =>
6873
Effect.scoped(
6974
Effect.gen(function* () {
7075
const broker = yield* PreviewAutomationBroker.__testing.make;
71-
const requests = yield* broker.connect("client-hidden");
76+
const requests = yield* broker.connect(
77+
makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }),
78+
);
79+
yield* Stream.runForEach(requests, (request) =>
80+
broker.respond({ requestId: request.requestId, ok: true }),
81+
).pipe(Effect.forkScoped);
82+
yield* Effect.yieldNow;
83+
84+
yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
85+
}),
86+
),
87+
);
88+
89+
it.effect("lets the browser host resolve an active tab that has not been reported yet", () =>
90+
Effect.scoped(
91+
Effect.gen(function* () {
92+
const broker = yield* PreviewAutomationBroker.__testing.make;
93+
const requests = yield* broker.connect(makeOwner({ tabId: null }));
94+
let routedTabId: string | undefined;
95+
yield* Stream.runForEach(requests, (request) => {
96+
routedTabId = request.tabId;
97+
return broker.respond({ requestId: request.requestId, ok: true });
98+
}).pipe(Effect.forkScoped);
99+
yield* Effect.yieldNow;
100+
101+
yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
102+
103+
expect(routedTabId).toBeUndefined();
104+
}),
105+
),
106+
);
107+
108+
it.effect("preserves current owner metadata when its request stream reconnects", () =>
109+
Effect.scoped(
110+
Effect.gen(function* () {
111+
const broker = yield* PreviewAutomationBroker.__testing.make;
112+
const firstRequests = yield* broker.connect(makeOwner());
113+
yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);
114+
yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true }));
115+
116+
const reconnectedRequests = yield* broker.connect(makeOwner());
117+
let routedTabId: string | undefined;
118+
yield* Stream.runForEach(reconnectedRequests, (request) => {
119+
routedTabId = request.tabId;
120+
return broker.respond({ requestId: request.requestId, ok: true });
121+
}).pipe(Effect.forkScoped);
122+
yield* Effect.yieldNow;
123+
124+
yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
125+
126+
expect(routedTabId).toBe("tab-current");
127+
}),
128+
),
129+
);
130+
131+
it.effect("ignores stale owner cleanup after the client moves to another thread", () =>
132+
Effect.scoped(
133+
Effect.gen(function* () {
134+
const broker = yield* PreviewAutomationBroker.__testing.make;
135+
const requests = yield* broker.connect(makeOwner());
72136
yield* Stream.runForEach(requests, (request) =>
73137
broker.respond({ requestId: request.requestId, ok: true }),
74138
).pipe(Effect.forkScoped);
75139
yield* Effect.yieldNow;
76-
yield* broker.reportOwner({
77-
clientId: "client-hidden",
140+
141+
yield* broker.clearOwner({
142+
clientId: "client-1",
78143
environmentId: scope.environmentId,
79-
threadId: scope.threadId,
80-
tabId: "tab-hidden",
81-
visible: false,
82-
supportsAutomation: true,
83-
focusedAt: "2026-06-11T00:00:00.000Z",
144+
threadId: ThreadId.make("thread-stale"),
84145
});
85146

86-
yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } });
147+
yield* broker.invoke<void>({ scope, operation: "status", input: {} });
148+
}),
149+
),
150+
);
151+
152+
it.effect("fails requests assigned to a browser stream when that stream reconnects", () =>
153+
Effect.scoped(
154+
Effect.gen(function* () {
155+
const broker = yield* PreviewAutomationBroker.__testing.make;
156+
const _requests = yield* broker.connect(makeOwner());
157+
const pending = yield* broker
158+
.invoke<void>({ scope, operation: "status", input: {} })
159+
.pipe(Effect.flip, Effect.forkScoped);
160+
yield* Effect.yieldNow;
161+
162+
const _replacementRequests = yield* broker.connect(makeOwner());
163+
164+
const error = yield* Fiber.join(pending);
165+
expect(error).toBeInstanceOf(PreviewAutomationUnavailableError);
166+
}),
167+
),
168+
);
169+
170+
it.effect("falls back to an older connected owner when a newer report is not connected", () =>
171+
Effect.scoped(
172+
Effect.gen(function* () {
173+
const broker = yield* PreviewAutomationBroker.__testing.make;
174+
const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" }));
175+
yield* Stream.runForEach(requests, (request) =>
176+
broker.respond({ requestId: request.requestId, ok: true, result: "connected" }),
177+
).pipe(Effect.forkScoped);
178+
yield* Effect.yieldNow;
179+
yield* broker.reportOwner(
180+
makeOwner({
181+
clientId: "client-report-only",
182+
focusedAt: "2026-06-11T00:00:01.000Z",
183+
}),
184+
);
185+
186+
const result = yield* broker.invoke<string>({ scope, operation: "status", input: {} });
187+
188+
expect(result).toBe("connected");
87189
}),
88190
),
89191
);

apps/server/src/mcp/PreviewAutomationBroker.ts

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type PreviewAutomationError,
1212
type PreviewAutomationOperation,
1313
type PreviewAutomationOwner,
14+
type PreviewAutomationOwnerIdentity,
1415
type PreviewAutomationRequest,
1516
type PreviewAutomationResponse,
1617
type PreviewTabId,
@@ -35,11 +36,13 @@ export interface PreviewAutomationInvokeInput {
3536
}
3637

3738
export interface PreviewAutomationBrokerShape {
38-
readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
39+
readonly connect: (
40+
owner: PreviewAutomationOwner,
41+
) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
3942
readonly reportOwner: (
4043
owner: PreviewAutomationOwner,
4144
) => Effect.Effect<void, PreviewAutomationError>;
42-
readonly clearOwner: (clientId: string) => Effect.Effect<void>;
45+
readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>;
4346
readonly respond: (
4447
response: PreviewAutomationResponse,
4548
) => Effect.Effect<void, PreviewAutomationError>;
@@ -63,7 +66,7 @@ interface ClientConnection {
6366
}
6467

6568
interface PendingRequest {
66-
readonly clientId: string;
69+
readonly queue: ClientConnection["queue"];
6770
readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>;
6871
}
6972

@@ -133,17 +136,16 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
133136
queue: ClientConnection["queue"],
134137
) {
135138
const toFail = yield* SynchronizedRef.modify(state, (current) => {
136-
if (current.clients.get(clientId)?.queue !== queue) {
137-
return [[] as ReadonlyArray<PendingRequest>, current] as const;
138-
}
139139
const clients = new Map(current.clients);
140140
const owners = new Map(current.owners);
141141
const pending = new Map(current.pending);
142142
const disconnected: PendingRequest[] = [];
143-
clients.delete(clientId);
144-
owners.delete(clientId);
143+
if (current.clients.get(clientId)?.queue === queue) {
144+
clients.delete(clientId);
145+
owners.delete(clientId);
146+
}
145147
for (const [requestId, entry] of pending) {
146-
if (entry.clientId === clientId) {
148+
if (entry.queue === queue) {
147149
pending.delete(requestId);
148150
disconnected.push(entry);
149151
}
@@ -166,12 +168,22 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
166168

167169
const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn(
168170
"PreviewAutomationBroker.connect",
169-
)(function* (clientId) {
171+
)(function* (owner) {
172+
const clientId = owner.clientId;
170173
const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>();
171174
const previous = yield* SynchronizedRef.modify(state, (current) => {
172175
const clients = new Map(current.clients);
176+
const owners = new Map(current.owners);
177+
const existingOwner = current.owners.get(clientId);
173178
clients.set(clientId, { clientId, queue });
174-
return [current.clients.get(clientId), { ...current, clients }] as const;
179+
owners.set(
180+
clientId,
181+
existingOwner?.environmentId === owner.environmentId &&
182+
existingOwner.threadId === owner.threadId
183+
? { ...existingOwner, supportsAutomation: owner.supportsAutomation }
184+
: owner,
185+
);
186+
return [current.clients.get(clientId), { ...current, clients, owners }] as const;
175187
});
176188
if (previous) yield* disconnect(clientId, previous.queue);
177189
return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
@@ -189,10 +201,18 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
189201

190202
const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn(
191203
"PreviewAutomationBroker.clearOwner",
192-
)(function* (clientId) {
204+
)(function* (owner) {
193205
yield* SynchronizedRef.update(state, (current) => {
206+
const currentOwner = current.owners.get(owner.clientId);
207+
if (
208+
!currentOwner ||
209+
currentOwner.environmentId !== owner.environmentId ||
210+
currentOwner.threadId !== owner.threadId
211+
) {
212+
return current;
213+
}
194214
const owners = new Map(current.owners);
195-
owners.delete(clientId);
215+
owners.delete(owner.clientId);
196216
return { ...current, owners };
197217
});
198218
});
@@ -234,8 +254,13 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
234254
owner.supportsAutomation,
235255
)
236256
.sort((left, right) => right.focusedAt.localeCompare(left.focusedAt));
237-
const owner = candidates[0];
257+
const owner = candidates.find((candidate) => current.clients.has(candidate.clientId));
238258
if (!owner) {
259+
if (candidates.length > 0) {
260+
return yield* new PreviewAutomationUnavailableError({
261+
message: "The browser host is not connected.",
262+
});
263+
}
239264
return yield* new PreviewAutomationNoFocusedOwnerError({
240265
message: "No desktop browser host is available for this thread.",
241266
});
@@ -246,22 +271,12 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
246271
message: "The browser host is not connected.",
247272
});
248273
}
249-
if (
250-
input.operation !== "open" &&
251-
input.operation !== "status" &&
252-
!owner.tabId &&
253-
!input.tabId
254-
) {
255-
return yield* new PreviewAutomationTabNotFoundError({
256-
message: "The browser host does not have an active tab.",
257-
});
258-
}
259274
const timeoutMs = input.timeoutMs ?? 15_000;
260275
const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
261276
const requestId = yield* SynchronizedRef.modify(state, (next) => {
262277
const requestId = `preview-${next.requestSequence}`;
263278
const pending = new Map(next.pending);
264-
pending.set(requestId, { clientId: owner.clientId, deferred });
279+
pending.set(requestId, { queue: connection.queue, deferred });
265280
return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const;
266281
});
267282
const removePending = SynchronizedRef.update(state, (next) => {

apps/server/src/ws.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,7 +1489,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
14891489
[WS_METHODS.previewAutomationConnect]: (input) =>
14901490
observeRpcStreamEffect(
14911491
WS_METHODS.previewAutomationConnect,
1492-
previewAutomationBroker.connect(input.clientId),
1492+
previewAutomationBroker.connect(input),
14931493
{ "rpc.aggregate": "preview-automation" },
14941494
),
14951495
[WS_METHODS.previewAutomationRespond]: (input) =>
@@ -1507,7 +1507,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
15071507
[WS_METHODS.previewAutomationClearOwner]: (input) =>
15081508
observeRpcEffect(
15091509
WS_METHODS.previewAutomationClearOwner,
1510-
previewAutomationBroker.clearOwner(input.clientId),
1510+
previewAutomationBroker.clearOwner(input),
15111511
{ "rpc.aggregate": "preview-automation" },
15121512
),
15131513
[WS_METHODS.subscribePreviewEvents]: (_input) =>

apps/web/src/components/preview/PreviewAutomationOwner.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { describe, expect, it } from "vite-plus/test";
33
import { observeAutomationOwnerConnectedGeneration } from "./PreviewAutomationOwner";
44

55
describe("observeAutomationOwnerConnectedGeneration", () => {
6-
it("re-reports ownership only after a later transport generation connects", () => {
6+
it("reports ownership when the initial transport generation connects", () => {
77
const initial = observeAutomationOwnerConnectedGeneration(null, 1);
88
expect(initial).toEqual({
99
nextGeneration: 1,
10-
shouldReport: false,
10+
shouldReport: true,
1111
});
1212

1313
const disconnected = observeAutomationOwnerConnectedGeneration(initial.nextGeneration, null);

0 commit comments

Comments
 (0)