From f7081b9c5bc2824e172d45cd382d8b8b072ca728 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 02:54:09 -0500 Subject: [PATCH 1/3] fix(desktop): grant clipboard permissions in preview browser The preview browser session allowed "clipboard-write", but the Chromium permission actually requested by navigator.clipboard.writeText is "clipboard-sanitized-write", so clipboard writes from previewed pages were silently denied. Electron also consults the synchronous permission check handler for clipboard APIs, and none was installed. Add "clipboard-sanitized-write" to the request allowlist and install a matching permission check handler. --- apps/desktop/src/preview/BrowserSession.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index afa8dafe976..89941f5fef0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -120,9 +120,24 @@ export const make = Effect.gen(function* BrowserSessionMake() { .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"]; + const allowed = [ + "clipboard-read", + "clipboard-write", + "clipboard-sanitized-write", + "notifications", + "geolocation", + ]; callback(allowed.includes(permission)); }); + browserSession.setPermissionCheckHandler((_webContents, permission) => { + const allowed = [ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", + ]; + return allowed.includes(permission); + }); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; From ae838b653261bb73afd81e121a3d797456ffbccd Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 03:07:28 -0500 Subject: [PATCH 2/3] test(desktop): cover preview clipboard permission handlers Add setPermissionCheckHandler to the mocked Electron session so getSession paths keep working, and assert both permission handlers grant clipboard access and deny unrelated permissions. --- .../src/preview/BrowserSession.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index e258bb2dfc5..c150dff685b 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -14,6 +14,7 @@ const { fromPartition, sessions } = vi.hoisted(() => ({ readonly clearCache: ReturnType; readonly clearStorageData: ReturnType; readonly getUserAgent: ReturnType; + readonly setPermissionCheckHandler: ReturnType; readonly setPermissionRequestHandler: ReturnType; readonly setUserAgent: ReturnType; } @@ -39,6 +40,7 @@ describe("BrowserSession", () => { clearCache: vi.fn(() => Promise.resolve()), clearStorageData: vi.fn(() => Promise.resolve()), getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"), + setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), setUserAgent: vi.fn(), }; @@ -61,6 +63,40 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("grants clipboard permissions for previewed pages", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const mockSession = sessions.get(partition); + assert.isDefined(mockSession); + + assert.strictEqual(mockSession.setPermissionRequestHandler.mock.calls.length, 1); + const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: unknown, + permission: string, + callback: (granted: boolean) => void, + ) => void; + const requested = new Map(); + for (const permission of ["clipboard-read", "clipboard-sanitized-write", "midi"]) { + requestHandler(undefined, permission, (granted) => requested.set(permission, granted)); + } + assert.strictEqual(requested.get("clipboard-read"), true); + assert.strictEqual(requested.get("clipboard-sanitized-write"), true); + assert.strictEqual(requested.get("midi"), false); + + assert.strictEqual(mockSession.setPermissionCheckHandler.mock.calls.length, 1); + const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: unknown, + permission: string, + ) => boolean; + assert.strictEqual(checkHandler(undefined, "clipboard-read"), true); + assert.strictEqual(checkHandler(undefined, "clipboard-sanitized-write"), true); + assert.strictEqual(checkHandler(undefined, "midi"), false); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves partition scope and the platform failure chain", () => { const nativeCause = new Error("native digest failed"); const platformCause = PlatformError.systemError({ From 306392826b7bc0ef6284761ac47e34264d7928f1 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Mon, 6 Jul 2026 03:16:13 -0500 Subject: [PATCH 3/3] refactor(desktop): hoist preview permission allowlists to module constants Share one base allowlist between the request and check handlers, with the legacy "clipboard-write" name added only where the request handler can receive it, so the intentional difference between the two lists is explicit. Set lookup also avoids rebuilding an array on every permission check, which Chromium invokes synchronously. --- .../src/preview/BrowserSession.test.ts | 20 ++++++----- apps/desktop/src/preview/BrowserSession.ts | 34 +++++++++---------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index c150dff685b..17fa48958a7 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -29,6 +29,13 @@ vi.mock("electron", () => ({ import * as BrowserSession from "./BrowserSession.ts"; +type PermissionRequestHandler = ( + webContents: unknown, + permission: string, + callback: (granted: boolean) => void, +) => void; +type PermissionCheckHandler = (webContents: unknown, permission: string) => boolean; + const layer = BrowserSession.layer.pipe(Layer.provide(NodeServices.layer)); describe("BrowserSession", () => { @@ -73,11 +80,8 @@ describe("BrowserSession", () => { assert.isDefined(mockSession); assert.strictEqual(mockSession.setPermissionRequestHandler.mock.calls.length, 1); - const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0]?.[0] as ( - webContents: unknown, - permission: string, - callback: (granted: boolean) => void, - ) => void; + const requestHandler = mockSession.setPermissionRequestHandler.mock + .calls[0]?.[0] as PermissionRequestHandler; const requested = new Map(); for (const permission of ["clipboard-read", "clipboard-sanitized-write", "midi"]) { requestHandler(undefined, permission, (granted) => requested.set(permission, granted)); @@ -87,10 +91,8 @@ describe("BrowserSession", () => { assert.strictEqual(requested.get("midi"), false); assert.strictEqual(mockSession.setPermissionCheckHandler.mock.calls.length, 1); - const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0]?.[0] as ( - webContents: unknown, - permission: string, - ) => boolean; + const checkHandler = mockSession.setPermissionCheckHandler.mock + .calls[0]?.[0] as PermissionCheckHandler; assert.strictEqual(checkHandler(undefined, "clipboard-read"), true); assert.strictEqual(checkHandler(undefined, "clipboard-sanitized-write"), true); assert.strictEqual(checkHandler(undefined, "midi"), false); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 89941f5fef0..0a35800ce89 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -11,6 +11,19 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; +const ALLOWED_PERMISSIONS = [ + "clipboard-read", + "clipboard-sanitized-write", + "notifications", + "geolocation", +] as const; +const PERMISSION_CHECK_ALLOWLIST: ReadonlySet = new Set(ALLOWED_PERMISSIONS); +// The request handler can also receive the legacy "clipboard-write" permission name. +const PERMISSION_REQUEST_ALLOWLIST: ReadonlySet = new Set([ + ...ALLOWED_PERMISSIONS, + "clipboard-write", +]); + export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( "BrowserSessionPartitionDerivationError", { @@ -120,24 +133,11 @@ export const make = Effect.gen(function* BrowserSessionMake() { .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = [ - "clipboard-read", - "clipboard-write", - "clipboard-sanitized-write", - "notifications", - "geolocation", - ]; - callback(allowed.includes(permission)); - }); - browserSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = [ - "clipboard-read", - "clipboard-sanitized-write", - "notifications", - "geolocation", - ]; - return allowed.includes(permission); + callback(PERMISSION_REQUEST_ALLOWLIST.has(permission)); }); + browserSession.setPermissionCheckHandler((_webContents, permission) => + PERMISSION_CHECK_ALLOWLIST.has(permission), + ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const;