From b19fc1b87b22a3c923e60c2ef2a1dae336279924 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:24:04 -0700 Subject: [PATCH 01/66] [codex] refactor desktop Electron Effect services (#3178) Co-authored-by: codex --- .../app/DesktopConnectionCatalogStore.test.ts | 3 - .../src/app/DesktopConnectionCatalogStore.ts | 13 +- apps/desktop/src/app/DesktopLifecycle.ts | 6 +- apps/desktop/src/electron/ElectronApp.ts | 71 +++--- apps/desktop/src/electron/ElectronDialog.ts | 29 ++- apps/desktop/src/electron/ElectronMenu.ts | 224 +++++++++--------- apps/desktop/src/electron/ElectronProtocol.ts | 34 +-- .../src/electron/ElectronSafeStorage.ts | 73 +++--- apps/desktop/src/electron/ElectronShell.ts | 17 +- apps/desktop/src/electron/ElectronTheme.ts | 19 +- .../src/electron/ElectronUpdater.test.ts | 1 + apps/desktop/src/electron/ElectronUpdater.ts | 103 ++++---- apps/desktop/src/electron/ElectronWindow.ts | 56 ++--- apps/desktop/src/main.ts | 8 +- .../settings/DesktopSavedEnvironments.test.ts | 3 - .../src/settings/DesktopSavedEnvironments.ts | 12 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 4 +- .../src/updates/DesktopUpdates.test.ts | 4 +- .../src/window/DesktopApplicationMenu.test.ts | 6 +- .../src/window/DesktopApplicationMenu.ts | 6 +- apps/desktop/src/window/DesktopWindow.ts | 12 +- 21 files changed, 351 insertions(+), 353 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 26c0c8f8943f..e0be7f39b39e 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -237,7 +237,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDocumentDecodeError, ); - assert.equal(error.operation, "decode-catalog-document"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); assert.equal(yield* fileSystem.readFileString(catalogPath), "{not-json"); @@ -272,7 +271,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreReadError, ); - assert.equal(error.operation, "read-catalog"); assert.equal(error.catalogPath, `${baseDir}/userdata/connection-catalog.json`); assert.strictEqual(error.cause, permissionError); assert.equal( @@ -368,7 +366,6 @@ describe("DesktopConnectionCatalogStore", () => { error, DesktopConnectionCatalogStore.DesktopConnectionCatalogStoreDecodeError, ); - assert.equal(error.operation, "decode-encrypted-catalog"); assert.equal(error.resource, "encryptedCatalog"); assert.equal(error.catalogPath, catalogPath); assert.exists(error.cause); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 7eaf3ec7cf68..8467fe3f0774 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -92,7 +92,6 @@ const writeError = ( export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDecodeError", { - operation: Schema.Literal("decode-encrypted-catalog"), resource: Schema.Literal("encryptedCatalog"), catalogPath: Schema.String, cause: Schema.Defect(), @@ -106,7 +105,6 @@ export class DesktopConnectionCatalogStoreDecodeError extends Schema.TaggedError export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreReadError", { - operation: Schema.Literal("read-catalog"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +117,6 @@ export class DesktopConnectionCatalogStoreReadError extends Schema.TaggedErrorCl export class DesktopConnectionCatalogStoreDocumentDecodeError extends Schema.TaggedErrorClass()( "DesktopConnectionCatalogStoreDocumentDecodeError", { - operation: Schema.Literal("decode-catalog-document"), catalogPath: Schema.String, cause: Schema.Defect(), }, @@ -167,16 +164,13 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreDocumentDecodeError | DesktopConnectionCatalogStoreDecodeError | DesktopConnectionCatalogStoreMigrationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError + | ElectronSafeStorage.ElectronSafeStorageError >; readonly set: ( catalog: string, ) => Effect.Effect< boolean, - | DesktopConnectionCatalogStoreWriteError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError + DesktopConnectionCatalogStoreWriteError | ElectronSafeStorage.ElectronSafeStorageError >; readonly clear: Effect.Effect; } @@ -190,7 +184,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDecodeError({ - operation: "decode-encrypted-catalog", resource: "encryptedCatalog", catalogPath, cause, @@ -212,7 +205,6 @@ const readDocument = ( ? Effect.succeed(null) : Effect.fail( new DesktopConnectionCatalogStoreReadError({ - operation: "read-catalog", catalogPath, cause: error, }), @@ -226,7 +218,6 @@ const readDocument = ( Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ - operation: "decode-catalog-document", catalogPath, cause, }), diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index b62662ad27b2..ad08d2f5a2ec 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -8,7 +8,7 @@ import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopObservability from "./DesktopObservability.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -37,7 +37,7 @@ export class DesktopLifecycle extends Context.Service< >()("@t3tools/desktop/app/DesktopLifecycle") {} const { logInfo: logLifecycleInfo, logError: logLifecycleError } = - DesktopObservability.makeComponentLogger("desktop-lifecycle"); + makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -122,7 +122,7 @@ function quitFromSignal( ); } -const make = DesktopLifecycle.of({ +export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 49b432fd5dde..3e894001e10d 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -13,41 +13,40 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } -export interface ElectronAppShape { - readonly metadata: Effect.Effect; - readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; - readonly quit: Effect.Effect; - readonly exit: (code: number) => Effect.Effect; - readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; - readonly setPath: ( - name: Parameters[0], - path: string, - ) => Effect.Effect; - readonly setName: (name: string) => Effect.Effect; - readonly setAboutPanelOptions: ( - options: Electron.AboutPanelOptionsOptions, - ) => Effect.Effect; - readonly setAppUserModelId: (id: string) => Effect.Effect; - readonly requestSingleInstanceLock: Effect.Effect; - readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; - readonly setAsDefaultProtocolClient: ( - protocol: string, - path?: string, - args?: readonly string[], - ) => Effect.Effect; - readonly setDesktopName: (desktopName: string) => Effect.Effect; - readonly setDockIcon: (iconPath: string) => Effect.Effect; - readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} - -export class ElectronApp extends Context.Service()( - "@t3tools/desktop/electron/ElectronApp", -) {} +export class ElectronApp extends Context.Service< + ElectronApp, + { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; + readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; + readonly setAsDefaultProtocolClient: ( + protocol: string, + path?: string, + args?: readonly string[], + ) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronApp") {} const addScopedAppListener = >( eventName: string, @@ -63,7 +62,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -const make = ElectronApp.of({ +export const make = ElectronApp.of({ metadata: Effect.sync(() => ({ appVersion: Electron.app.getVersion(), appPath: Electron.app.getAppPath(), diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 74e6ae588482..057817ec7e63 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -17,22 +17,21 @@ export interface ElectronDialogConfirmInput { readonly message: string; } -export interface ElectronDialogShape { - readonly pickFolder: ( - input: ElectronDialogPickFolderInput, - ) => Effect.Effect>; - readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; - readonly showMessageBox: ( - options: Electron.MessageBoxOptions, - ) => Effect.Effect; - readonly showErrorBox: (title: string, content: string) => Effect.Effect; -} - -export class ElectronDialog extends Context.Service()( - "@t3tools/desktop/electron/ElectronDialog", -) {} +export class ElectronDialog extends Context.Service< + ElectronDialog, + { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect>; + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronDialog") {} -const make = ElectronDialog.of({ +export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { onNone: () => ({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 2ffda3dc5076..d9eb3b22effd 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,11 +1,11 @@ import type { ContextMenuItem } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; export interface ElectronMenuPosition { readonly x: number; @@ -23,19 +23,18 @@ export interface ElectronMenuTemplateInput { readonly template: readonly Electron.MenuItemConstructorOptions[]; } -export interface ElectronMenuShape { - readonly setApplicationMenu: ( - template: readonly Electron.MenuItemConstructorOptions[], - ) => Effect.Effect; - readonly showContextMenu: ( - input: ElectronMenuContextInput, - ) => Effect.Effect>; - readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; -} - -export class ElectronMenu extends Context.Service()( - "@t3tools/desktop/electron/ElectronMenu", -) {} +export class ElectronMenu extends Context.Service< + ElectronMenu, + { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronMenu") {} function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; @@ -80,114 +79,113 @@ const normalizePosition = ( ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); -export const layer = Layer.effect( - ElectronMenu, - Effect.gen(function* () { - const platform = yield* HostProcessPlatform; - let destructiveMenuIconCache: Option.Option | undefined; +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + let destructiveMenuIconCache: Option.Option | undefined; - const getDestructiveMenuIcon = (): Option.Option => { - if (platform !== "darwin") { - return Option.none(); - } - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache; - } + const getDestructiveMenuIcon = (): Option.Option => { + if (platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } - try { - const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ - width: 12, - height: 12, - }); - icon.setTemplateImage(true); - destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); - } catch { - destructiveMenuIconCache = Option.none(); - } + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + icon.setTemplateImage(true); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } - return destructiveMenuIconCache; - }; + return destructiveMenuIconCache; + }; - const buildTemplate = ( - entries: readonly ContextMenuItem[], - complete: (selectedItemId: Option.Option) => void, - ): Electron.MenuItemConstructorOptions[] => { - const template: Electron.MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; - const itemOption: Electron.MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children, complete); - } else { - itemOption.click = () => complete(Option.some(item.id)); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (Option.isSome(destructiveIcon)) { - itemOption.icon = destructiveIcon.value; - } - } + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } - template.push(itemOption); + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } } - return template; - }; + template.push(itemOption); + } - return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), - popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), - showContextMenu: (input) => - Effect.callback>((resume) => { - const normalizedItems = normalizeContextMenuItems(input.items); - if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + return template; + }; + + return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { return; } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); +}); - let completed = false; - const complete = (selectedItemId: Option.Option) => { - if (completed) { - return; - } - completed = true; - resume(Effect.succeed(selectedItemId)); - }; - - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); - }), - }); - }), -); +export const layer = Layer.effect(ElectronMenu, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 3a3e9f180f78..4c80c2c4900b 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,8 +1,8 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -23,13 +23,14 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } -export class ElectronProtocolRegistrationError extends Data.TaggedError( +export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", -)<{ - readonly scheme: string; - readonly cause: unknown; -}> { - override get message() { + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { return `Failed to register ${this.scheme}: protocol.`; } } @@ -41,15 +42,14 @@ export interface DesktopProtocolRegistrationInput { readonly clerkFrontendApiHostname: string | undefined; } -export interface ElectronProtocolShape { - readonly registerDesktopProtocol: ( - input: DesktopProtocolRegistrationInput, - ) => Effect.Effect; -} - -export class ElectronProtocol extends Context.Service()( - "@t3tools/desktop/electron/ElectronProtocol", -) {} +export class ElectronProtocol extends Context.Service< + ElectronProtocol, + { + readonly registerDesktopProtocol: ( + input: DesktopProtocolRegistrationInput, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronProtocol") {} export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string { const clerkOrigin = input.clerkFrontendApiHostname @@ -114,7 +114,7 @@ async function proxyRequest( return withContentSecurityPolicy(response, contentSecurityPolicy); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 853133705476..76162c1647a0 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,56 +1,69 @@ -import * as Electron from "electron"; - +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( +import * as Electron from "electron"; + +const electronSafeStorageErrorFields = { + cause: Schema.Defect(), +}; + +export class ElectronSafeStorageAvailabilityError extends Schema.TaggedErrorClass()( "ElectronSafeStorageAvailabilityError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to check encryption availability."; } } -export class ElectronSafeStorageEncryptError extends Data.TaggedError( +export class ElectronSafeStorageEncryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageEncryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to encrypt a string."; } } -export class ElectronSafeStorageDecryptError extends Data.TaggedError( +export class ElectronSafeStorageDecryptError extends Schema.TaggedErrorClass()( "ElectronSafeStorageDecryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronSafeStorageErrorFields, + }, +) { + override get message(): string { return "Electron safe storage failed to decrypt a string."; } } -export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; -} +export const ElectronSafeStorageError = Schema.Union([ + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageEncryptError, + ElectronSafeStorageDecryptError, +]); +export type ElectronSafeStorageError = typeof ElectronSafeStorageError.Type; +export const isElectronSafeStorageError = Schema.is(ElectronSafeStorageError); export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, - ElectronSafeStorageShape + { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; + } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -const make = ElectronSafeStorage.of({ +export const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0ecce3bf70ec..316d3138bfa6 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -20,16 +20,15 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { } } -export interface ElectronShellShape { - readonly openExternal: (rawUrl: unknown) => Effect.Effect; - readonly copyText: (text: string) => Effect.Effect; -} - -export class ElectronShell extends Context.Service()( - "@t3tools/desktop/electron/ElectronShell", -) {} +export class ElectronShell extends Context.Service< + ElectronShell, + { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronShell") {} -const make = ElectronShell.of({ +export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 1e23d228504e..ef99a31067a6 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -6,17 +6,16 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; -export interface ElectronThemeShape { - readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; - readonly onUpdated: (listener: () => void) => Effect.Effect; -} +export class ElectronTheme extends Context.Service< + ElectronTheme, + { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronTheme") {} -export class ElectronTheme extends Context.Service()( - "@t3tools/desktop/electron/ElectronTheme", -) {} - -const make = ElectronTheme.of({ +export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => Effect.suspend(() => { diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d2d3edd36962..43a3c84dcd4a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -73,6 +73,7 @@ describe("ElectronUpdater", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); assert.equal(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates."); } }).pipe(Effect.provide(ElectronUpdater.layer)), ); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 7f3edf02aa85..8a468a15c20e 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,7 +1,7 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { autoUpdater } from "electron-updater"; @@ -10,67 +10,76 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( +const electronUpdaterErrorFields = { + cause: Schema.Defect(), +}; + +export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to check for updates."; } } -export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( +export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to download the update."; } } -export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( +export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", -)<{ - readonly cause: unknown; -}> { - override get message() { + { + ...electronUpdaterErrorFields, + }, +) { + override get message(): string { return "Electron updater failed to quit and install the update."; } } -export type ElectronUpdaterError = - | ElectronUpdaterCheckForUpdatesError - | ElectronUpdaterDownloadUpdateError - | ElectronUpdaterQuitAndInstallError; +export const ElectronUpdaterError = Schema.Union([ + ElectronUpdaterCheckForUpdatesError, + ElectronUpdaterDownloadUpdateError, + ElectronUpdaterQuitAndInstallError, +]); +export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; +export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); -export interface ElectronUpdaterShape { - readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; - readonly setAutoDownload: (value: boolean) => Effect.Effect; - readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; - readonly setChannel: (channel: string) => Effect.Effect; - readonly setAllowPrerelease: (value: boolean) => Effect.Effect; - readonly allowDowngrade: Effect.Effect; - readonly setAllowDowngrade: (value: boolean) => Effect.Effect; - readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; - readonly quitAndInstall: (options: { - readonly isSilent: boolean; - readonly isForceRunAfter: boolean; - }) => Effect.Effect; - readonly on: >( - eventName: string, - listener: (...args: Args) => void, - ) => Effect.Effect; -} - -export class ElectronUpdater extends Context.Service()( - "@t3tools/desktop/electron/ElectronUpdater", -) {} +export class ElectronUpdater extends Context.Service< + ElectronUpdater, + { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronUpdater") {} -export const layer = Layer.succeed(ElectronUpdater, { +export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { autoUpdater.setFeedURL(options); @@ -136,4 +145,6 @@ export const layer = Layer.succeed(ElectronUpdater, { }), ).pipe(Effect.asVoid); }, -} satisfies ElectronUpdaterShape); +}); + +export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 35c1fbc5faa6..0bf98a9610ec 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,43 +1,45 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ - readonly cause: unknown; -}> { - override get message() { +export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( + "ElectronWindowCreateError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { return "Failed to create Electron BrowserWindow."; } } -export interface ElectronWindowShape { - readonly create: ( - options: Electron.BrowserWindowConstructorOptions, - ) => Effect.Effect; - readonly main: Effect.Effect>; - readonly currentMainOrFirst: Effect.Effect>; - readonly focusedMainOrFirst: Effect.Effect>; - readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; - readonly clearMain: (window: Option.Option) => Effect.Effect; - readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; - readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; - readonly destroyAll: Effect.Effect; - readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, - ) => Effect.Effect; -} - -export class ElectronWindow extends Context.Service()( - "@t3tools/desktop/electron/ElectronWindow", -) {} +export class ElectronWindow extends Context.Service< + ElectronWindow, + { + readonly create: ( + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronWindow") {} -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; const mainWindowRef = yield* Ref.make>(Option.none()); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a6ffd9cdab12..f4b32db07c74 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as NodeOS from "node:os"; +import { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -19,7 +19,7 @@ import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; -import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), + homeDirectory: homedir(), platform, processArch, ...metadata, @@ -106,7 +106,7 @@ const electronLayer = Layer.mergeAll( ElectronDialog.layer, ElectronMenu.layer, ElectronProtocol.layer, - DesktopSecretStorage.layer, + ElectronSafeStorage.layer, ElectronShell.layer, ElectronTheme.layer, ElectronUpdater.layer, diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 4e3c8d8ba1d6..abd25a39f5bd 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -241,7 +241,6 @@ describe("DesktopSavedEnvironments", () => { .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentSecretDecodeError); - assert.equal(error.operation, "decode-secret"); assert.equal(error.environmentId, savedRegistryRecord.environmentId); assert.equal(error.registryPath, environment.savedEnvironmentRegistryPath); assert.equal(error.field, "encryptedBearerToken"); @@ -363,7 +362,6 @@ describe("DesktopSavedEnvironments", () => { registryError, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); - assert.equal(registryError.operation, "decode-registry"); assert.equal(registryError.registryPath, environment.savedEnvironmentRegistryPath); assert.exists(registryError.cause); const secretError = yield* savedEnvironments @@ -409,7 +407,6 @@ describe("DesktopSavedEnvironments", () => { const error = yield* savedEnvironments.getRegistry.pipe(Effect.flip); assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsReadError); - assert.equal(error.operation, "read-registry"); assert.equal(error.registryPath, registryPath); assert.strictEqual(error.cause, permissionError); assert.equal(error.message, `Failed to read desktop saved environments at ${registryPath}.`); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 490777e9e843..64c40d39f0e3 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -106,7 +106,6 @@ const writeError = ( export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsReadError", { - operation: Schema.Literal("read-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -119,7 +118,6 @@ export class DesktopSavedEnvironmentsReadError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentsDocumentDecodeError", { - operation: Schema.Literal("decode-registry"), registryPath: Schema.String, cause: Schema.Defect(), }, @@ -132,7 +130,6 @@ export class DesktopSavedEnvironmentsDocumentDecodeError extends Schema.TaggedEr export class DesktopSavedEnvironmentSecretDecodeError extends Schema.TaggedErrorClass()( "DesktopSavedEnvironmentSecretDecodeError", { - operation: Schema.Literal("decode-secret"), environmentId: Schema.String, registryPath: Schema.String, field: Schema.Literal("encryptedBearerToken"), @@ -155,13 +152,11 @@ export type DesktopSavedEnvironmentsMutationError = export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageDecryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export type DesktopSavedEnvironmentsSetSecretError = | DesktopSavedEnvironmentsMutationError - | ElectronSafeStorage.ElectronSafeStorageAvailabilityError - | ElectronSafeStorage.ElectronSafeStorageEncryptError; + | ElectronSafeStorage.ElectronSafeStorageError; export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, @@ -248,7 +243,6 @@ function readRegistryDocument( ? Effect.succeed(null) : Effect.fail( new DesktopSavedEnvironmentsReadError({ - operation: "read-registry", registryPath, cause: error, }), @@ -262,7 +256,6 @@ function readRegistryDocument( Effect.mapError( (cause) => new DesktopSavedEnvironmentsDocumentDecodeError({ - operation: "decode-registry", registryPath, cause, }), @@ -329,7 +322,6 @@ function decodeSecretBytes( Effect.mapError( (cause) => new DesktopSavedEnvironmentSecretDecodeError({ - operation: "decode-secret", environmentId, registryPath, field: "encryptedBearerToken", diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 080a2fe465d0..f0b5b1bd8ef7 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -9,7 +9,7 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as IpcChannels from "../ipc/channels.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -111,7 +111,7 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; assert.ok(sent); - assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); + assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL); const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; assert.equal(request.destination, "devbox"); assert.equal(testWindow.isRestored(), true); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 34d18f11a77d..ad234df0bb5d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -83,7 +83,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); }), ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdaterShape); + } satisfies ElectronUpdater.ElectronUpdater["Service"]); const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), @@ -99,7 +99,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), destroyAll: Effect.void, syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindowShape); + } satisfies ElectronWindow.ElectronWindow["Service"]); const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f3444c629f79..04a1971ce461 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -46,14 +46,14 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, on: () => Effect.void, -} satisfies ElectronApp.ElectronAppShape); +} satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeed(Option.none()), confirm: () => Effect.succeed(false), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, -} satisfies ElectronDialog.ElectronDialogShape); +} satisfies ElectronDialog.ElectronDialog["Service"]); const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), @@ -86,7 +86,7 @@ const makeElectronMenuLayer = ( Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, showContextMenu: () => Effect.succeed(Option.none()), - } satisfies ElectronMenu.ElectronMenuShape); + } satisfies ElectronMenu.ElectronMenu["Service"]); describe("DesktopApplicationMenu", () => { it.effect("installs the native menu and routes Settings through DesktopWindow", () => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 733c1f5494d8..cfe4f5702a1d 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -26,9 +26,9 @@ type DesktopApplicationMenuRuntimeServices = | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; -const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); +const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); -const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); +const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1822bb0c98e0..e6cfce3c54fe 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,15 +8,15 @@ import type * as Electron from "electron"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; +import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; -import * as PreviewManager from "../preview/Manager.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; -import * as IpcChannels from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -57,7 +57,7 @@ export class DesktopWindow extends Context.Service< >()("@t3tools/desktop/window/DesktopWindow") {} const { logInfo: logWindowInfo, logWarning: logWindowWarning } = - DesktopObservability.makeComponentLogger("desktop-window"); + makeComponentLogger("desktop-window"); function getIconOption( iconPaths: DesktopAssets.DesktopIconPaths, @@ -380,7 +380,7 @@ export const make = Effect.gen(function* () { const send = () => { if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); void runPromise(electronWindow.reveal(targetWindow)); }; From 97e5cd3bf7bbee427a177d5017aa2d250429bbf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:34:52 -0700 Subject: [PATCH 02/66] [codex] align server auth Effect services (#3180) Co-authored-by: codex --- apps/desktop/src/main.ts | 4 +- apps/server/src/assets/AssetAccess.ts | 12 +- apps/server/src/auth/EnvironmentAuth.test.ts | 27 +- apps/server/src/auth/EnvironmentAuth.ts | 714 ++++++++++++------ .../src/auth/EnvironmentAuthAdmin.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 20 +- .../server/src/auth/PairingGrantStore.test.ts | 10 +- apps/server/src/auth/PairingGrantStore.ts | 232 ++++-- .../server/src/auth/ServerSecretStore.test.ts | 8 +- apps/server/src/auth/ServerSecretStore.ts | 205 +++-- apps/server/src/auth/SessionStore.test.ts | 8 +- apps/server/src/auth/SessionStore.ts | 493 ++++++++---- apps/server/src/auth/dpop.test.ts | 16 +- apps/server/src/auth/dpop.ts | 30 +- apps/server/src/auth/http.ts | 78 +- apps/server/src/cloud/environmentKeys.test.ts | 8 +- apps/server/src/cloud/environmentKeys.ts | 35 +- apps/server/src/cloud/http.test.ts | 28 +- apps/server/src/cloud/http.ts | 232 +++--- apps/server/src/http.ts | 10 +- .../src/relay/AgentAwarenessRelay.test.ts | 12 +- apps/server/src/ws.ts | 10 +- 22 files changed, 1434 insertions(+), 764 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f4b32db07c74..b88eb18e57f9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: homedir(), + homeDirectory: NodeOS.homedir(), platform, processArch, ...metadata, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cf3c40f57c7d..873e9fc3d371 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,9 +19,9 @@ import { signPayload, timingSafeEqualBase64Url, } from "../auth/utils.ts"; -import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "attachment": { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, @@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); @@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orElseSucceed(() => null)); @@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; if (claims.kind === "attachment") { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: claims.attachmentId, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index b917cadb980b..335e0685197b 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInvalidError({ - message: "Unknown bootstrap credential.", - }), + new PairingGrantStore.UnknownBootstrapCredentialError({}), ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - if (error._tag === "ServerAuthInvalidCredentialError") { - expect(error.reason).toBe("invalid_credential"); - } }), ); it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - message: "Failed to consume bootstrap credential.", - cause: new Error("sqlite is unavailable"), - }), - ); + const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ + cause: new Error("sqlite is unavailable"), + }); + const error = EnvironmentAuth.toBootstrapExchangeError(cause); - expect(error._tag).toBe("ServerAuthInternalError"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); expect(error.message).toBe("Failed to validate bootstrap credential."); + if (error._tag === "ServerAuthBootstrapCredentialValidationError") { + expect(error.cause).toBe(cause); + } }), ); @@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ) .pipe(Effect.flip); - expect(error._tag).toBe("ServerAuthInvalidRequestError"); - if (error._tag === "ServerAuthInvalidRequestError") { - expect(error.reason).toBe("scope_not_granted"); - } + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d8c0079089fc..dd53a83ca957 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -20,12 +20,12 @@ import { import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; @@ -67,123 +67,429 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const serverAuthInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthBootstrapCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate bootstrap credential."; + } +} + +export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate session credential."; + } +} + +export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedSessionIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated session."; + } +} + +export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedAccessTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated access token."; + } +} + +export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkCreationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to create pairing link."; + } +} + +export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinksListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list pairing links."; + } +} + +export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} + +export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthSessionTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session token."; + } +} + +export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( + "ServerAuthSessionsListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list sessions."; + } +} + +export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthOtherSessionsRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthWebSocketTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayStateRecordError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to record DPoP proof replay state."; + } +} + +export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayKeyCalculationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to calculate DPoP replay key."; + } +} + +export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountVerificationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not verify the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not read the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountMissingError", + {}, +) { + override get message(): string { + return "Cloud linked user is not installed for this environment."; + } +} + +export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudLinkJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud link JWT."; + } +} + +export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintPublicKeyMissingError", + {}, +) { + override get message(): string { + return "Cloud mint public key is not installed for this environment."; + } +} + +export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudRelayIssuerMissingError", + {}, +) { + override get message(): string { + return "Cloud relay issuer is not installed for this environment."; + } +} -export class ServerAuthInvalidCredentialError extends Data.TaggedError( +export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudHealthJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud health JWT."; + } +} + +export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud mint JWT."; + } +} + +export const ServerAuthInternalError = Schema.Union([ + ServerAuthBootstrapCredentialValidationError, + ServerAuthSessionCredentialValidationError, + ServerAuthAuthenticatedSessionIssueError, + ServerAuthAuthenticatedAccessTokenIssueError, + ServerAuthPairingLinkCreationError, + ServerAuthPairingLinksListError, + ServerAuthPairingLinkRevocationError, + ServerAuthSessionTokenIssueError, + ServerAuthSessionsListError, + ServerAuthSessionRevocationError, + ServerAuthOtherSessionsRevocationError, + ServerAuthWebSocketTokenIssueError, + ServerAuthDpopReplayStateRecordError, + ServerAuthDpopReplayKeyCalculationError, + ServerAuthLinkedCloudAccountVerificationError, + ServerAuthLinkedCloudAccountReadError, + ServerAuthLinkedCloudAccountMissingError, + ServerAuthCloudLinkJwtSigningError, + ServerAuthCloudMintPublicKeyMissingError, + ServerAuthCloudRelayIssuerMissingError, + ServerAuthCloudHealthJwtSigningError, + ServerAuthCloudMintJwtSigningError, +]); +export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; +export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); + +export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( + "ServerAuthMissingCredentialError", + {}, +) { + override get message(): string { + return "Server authentication credential is missing."; + } +} + +export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass()( "ServerAuthInvalidCredentialError", -)<{ - readonly reason: "missing_credential" | "invalid_credential"; - readonly cause?: unknown; -}> {} - -export class ServerAuthInvalidRequestError extends Data.TaggedError( - "ServerAuthInvalidRequestError", -)<{ - readonly reason: "invalid_scope" | "scope_not_granted"; -}> {} - -export class ServerAuthForbiddenOperationError extends Data.TaggedError( - "ServerAuthForbiddenOperationError", -)<{ - readonly reason: "current_session_revoke_not_allowed"; -}> {} + { + diagnostic: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Server authentication credential is invalid."; + } +} -export interface EnvironmentAuthShape { - readonly getDescriptor: () => Effect.Effect; - readonly getSessionState: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly createBrowserSession: ( - credential: string, - requestMetadata: AuthClientMetadata, - ) => Effect.Effect< - { - readonly response: AuthBrowserSessionResult; - readonly sessionToken: string; - }, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly exchangeBootstrapCredentialForAccessToken: ( - credential: string, - requestedScopes: ReadonlyArray | undefined, - requestMetadata: AuthClientMetadata, - input?: { - readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect< - AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError - >; - readonly createPairingLink: (input?: { - readonly ttl?: Duration.Duration; - readonly label?: string; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly issuePairingCredential: ( - input?: AuthCreatePairingCredentialInput, - ) => Effect.Effect; - readonly issueStartupPairingCredential: () => Effect.Effect< - AuthPairingCredentialResult, - ServerAuthInternalError - >; - readonly listPairingLinks: (input?: { - readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, ServerAuthInternalError>; - readonly revokePairingLink: (id: string) => Effect.Effect; - readonly issueSession: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly scopes?: ReadonlyArray; - readonly label?: string; - }) => Effect.Effect; - readonly listSessions: () => Effect.Effect< - ReadonlyArray, - ServerAuthInternalError - >; - readonly revokeSession: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherSessionsExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly listClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect, ServerAuthInternalError>; - readonly revokeClientSession: ( - currentSessionId: AuthSessionId, - targetSessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeOtherClientSessions: ( - currentSessionId: AuthSessionId, - ) => Effect.Effect; - readonly authenticateHttpRequest: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly issueWebSocketTicket: ( - session: Pick, - ) => Effect.Effect; - readonly issueStartupPairingUrl: ( - baseUrl: string, - ) => Effect.Effect; +export const ServerAuthCredentialError = Schema.Union([ + ServerAuthMissingCredentialError, + ServerAuthInvalidCredentialError, +]); +export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; +export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); +export const serverAuthCredentialReason = ( + error: ServerAuthCredentialError, +): "missing_credential" | "invalid_credential" => + error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; + +export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( + "ServerAuthInvalidScopeError", + {}, +) { + override get message(): string { + return "The requested authentication scope is invalid."; + } } -export class EnvironmentAuth extends Context.Service()( - "t3/auth/EnvironmentAuth", -) {} +export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass()( + "ServerAuthScopeNotGrantedError", + {}, +) { + override get message(): string { + return "The requested authentication scope was not granted."; + } +} + +export const ServerAuthInvalidRequestError = Schema.Union([ + ServerAuthInvalidScopeError, + ServerAuthScopeNotGrantedError, +]); +export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; +export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); +export const serverAuthInvalidRequestReason = ( + error: ServerAuthInvalidRequestError, +): "invalid_scope" | "scope_not_granted" => + error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; + +export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( + "ServerAuthForbiddenOperationError", + {}, +) { + override get message(): string { + return "The current authentication session cannot revoke itself."; + } +} + +export class EnvironmentAuth extends Context.Service< + EnvironmentAuth, + { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly createBrowserSession: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect< + { + readonly response: AuthBrowserSessionResult; + readonly sessionToken: string; + }, + ServerAuthInvalidCredentialError | ServerAuthInternalError + >; + readonly exchangeBootstrapCredentialForAccessToken: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + AuthAccessTokenResult, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; + readonly createPairingLink: (input?: { + readonly ttl?: Duration.Duration; + readonly label?: string; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly issuePairingCredential: ( + input?: AuthCreatePairingCredentialInput, + ) => Effect.Effect; + readonly issueStartupPairingCredential: () => Effect.Effect< + AuthPairingCredentialResult, + ServerAuthInternalError + >; + readonly listPairingLinks: (input?: { + readonly excludeSubjects?: ReadonlyArray; + }) => Effect.Effect, ServerAuthInternalError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly issueSession: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly scopes?: ReadonlyArray; + readonly label?: string; + }) => Effect.Effect; + readonly listSessions: () => Effect.Effect< + ReadonlyArray, + ServerAuthInternalError + >; + readonly revokeSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherSessionsExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, ServerAuthInternalError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueWebSocketTicket: ( + session: Pick, + ) => Effect.Effect; + readonly issueStartupPairingUrl: ( + baseUrl: string, + ) => Effect.Effect; + } +>()("t3/auth/EnvironmentAuth") {} type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -const toInternalError = - (message: string) => - (cause: unknown): ServerAuthInternalError => - new ServerAuthInternalError({ message, cause }); - export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { - if (cause._tag === "BootstrapCredentialInternalError") { - return new ServerAuthInternalError({ - message: "Failed to validate bootstrap credential.", - cause, - }); + if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { + return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", cause, }); } @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTags({ - SessionCredentialInvalidError: (cause) => - Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), - SessionCredentialInternalError: (cause) => - Effect.fail( - new ServerAuthInternalError({ - message: "Failed to validate session credential.", - cause, - }), - ), - }), + Effect.mapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? new ServerAuthInvalidCredentialError({ cause }) + : new ServerAuthSessionCredentialValidationError({ cause }), + ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } -export const make = Effect.fn("makeEnvironmentAuth")(function* () { +export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ServerAuthInvalidCredentialError | ServerAuthInternalError > => sessions.verify(token).pipe( - Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => - Effect.logWarning("Rejected authenticated session credential.").pipe( - Effect.annotateLogs({ - reason: cause.message, - }), - ), + Effect.tapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ) + : Effect.void, ), Effect.map((session) => ({ sessionId: session.sessionId, @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { mapSessionVerificationErrors, ); - const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const authenticateRequest = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { - return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); + return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (!dpopToken || dpopToken !== credential) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP-bound access token requires DPoP authorization.", + diagnostic: "DPoP-bound access token requires DPoP authorization.", }), ); } @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (dpopToken) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP authorization requires a proof-bound access token.", + diagnostic: "DPoP authorization requires a proof-bound access token.", }), ); } @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ); }; - const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => + const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => authenticateRequest(request).pipe( Effect.map( (session) => @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchTag("ServerAuthInvalidCredentialError", () => + Effect.catchIf(isServerAuthCredentialError, () => Effect.succeed({ authenticated: false, auth: descriptor, @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, ) => @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated session.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), ), ), Effect.map( @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => bootstrapCredentials.consume(credential, input).pipe( Effect.mapError(toBootstrapExchangeError), @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthInvalidRequestError({ - reason: "scope_not_granted", - }); + return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }) .pipe( Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated access token.", - cause, - }), + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ), ); - const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( + const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", )( function* (input) { @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, - Effect.mapError(toInternalError("Failed to create pairing link.")), + Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); - const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => + const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), - Effect.mapError(toInternalError("Failed to list pairing links.")), + Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); - const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => - bootstrapCredentials - .revoke(id) - .pipe( - Effect.mapError(toInternalError("Failed to revoke pairing link.")), - Effect.withSpan("EnvironmentAuth.revokePairingLink"), - ); + const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => + bootstrapCredentials.revoke(id).pipe( + Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokePairingLink"), + ); - const issueSession: EnvironmentAuthShape["issueSession"] = (input) => + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("Failed to issue session token.")), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); - const listSessions: EnvironmentAuthShape["listSessions"] = () => + const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), - Effect.mapError(toInternalError("Failed to list sessions.")), + Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); - const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => - sessions - .revoke(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke session.")), - Effect.withSpan("EnvironmentAuth.revokeSession"), - ); + const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => + sessions.revoke(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeSession"), + ); - const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( + const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( sessionId, ) => - sessions - .revokeAllExcept(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke other sessions.")), - Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), - ); + sessions.revokeAllExcept(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), + ); - const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => + const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => issuePairingCredentialForSubject({ scopes: input?.scopes ?? AuthStandardClientScopes, subject: "one-time-token", ...(input?.label ? { label: input.label } : {}), }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); - const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () => - issuePairingCredentialForSubject({ - scopes: AuthAdministrativeScopes, - subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = + () => + issuePairingCredentialForSubject({ + scopes: AuthAdministrativeScopes, + subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); - const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => + const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => clientSessions.map( @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.listClientSessions"), ); - const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( + const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({ - reason: "current_session_revoke_not_allowed", - }); + return yield* new ServerAuthForbiddenOperationError({}); } return yield* revokeSession(targetSessionId); }); - const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( + const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( currentSessionId, ) => revokeOtherSessionsExcept(currentSessionId).pipe( Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); - const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { const url = new URL(baseUrl); @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), ); - const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => + const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue websocket token.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), ); - const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => + const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( + request, + ) => authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); - const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = + const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { return yield* authenticateRequest(request); }); - return { + return EnvironmentAuth.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), getSessionState, @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, - } satisfies EnvironmentAuthShape; + }); }); -export const layer = Layer.effect(EnvironmentAuth, make()).pipe( +export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 7dcc89761be8..03009270e15c 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-auth-control-plane-test-", + }), + ), ); const makeEnvironmentAuthLayer = ( diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 205c85b02345..7ffef0ff0a5f 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; -export interface EnvironmentAuthPolicyShape { - readonly getDescriptor: () => Effect.Effect; -} - export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, - EnvironmentAuthPolicyShape + { + readonly getDescriptor: () => Effect.Effect; + } >()("t3/auth/EnvironmentAuthPolicy") {} -export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { - const config = yield* ServerConfig; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { }), }; - return { + return EnvironmentAuthPolicy.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), - } satisfies EnvironmentAuthPolicyShape; + }); }); -export const layer = Layer.effect(EnvironmentAuthPolicy, make()); +export const layer = Layer.effect(EnvironmentAuthPolicy, make); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 12b0060094ab..b3c9b30f643d 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); @@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(successes).toHaveLength(1); expect(failures).toHaveLength(7); for (const failure of failures) { - expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError"); + expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError"); expect(failure.failure.message).toContain("Unknown bootstrap credential"); } }).pipe(Effect.provide(makePairingGrantStoreLayer())), @@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { "relay:write", ]); expect(first.subject).toBe("desktop-bootstrap"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); }).pipe( Effect.provide( makePairingGrantStoreLayer({ @@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { yield* TestClock.adjust(Duration.minutes(6)); const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); - expect(expired._tag).toBe("BootstrapCredentialInvalidError"); + expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); expect(expired.message).toContain("Bootstrap credential expired"); }).pipe( Effect.provide( @@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); expect(revokedConsume.message).toContain("no longer available"); - expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError"); + expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index c655a0f36b67..8a7a4d2e40fc 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -7,17 +7,17 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { @@ -29,22 +29,110 @@ export interface BootstrapGrant { readonly expiresAt: DateTime.DateTime; } -export class BootstrapCredentialInvalidError extends Data.TaggedError( - "BootstrapCredentialInvalidError", -)<{ - readonly message: string; -}> {} +export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnknownBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Unknown bootstrap credential."; + } +} + +export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass()( + "ExpiredBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential expired."; + } +} + +export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass()( + "BootstrapCredentialProofKeyMismatchError", + {}, +) { + override get message(): string { + return "Bootstrap credential proof key mismatch."; + } +} + +export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnavailableBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential is no longer available."; + } +} + +export const BootstrapCredentialInvalidError = Schema.Union([ + UnknownBootstrapCredentialError, + ExpiredBootstrapCredentialError, + BootstrapCredentialProofKeyMismatchError, + UnavailableBootstrapCredentialError, +]); +export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; +export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); + +export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( + "ActivePairingLinksLoadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load active pairing links."; + } +} + +export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( + "PairingLinkRevokeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} -export class BootstrapCredentialInternalError extends Data.TaggedError( - "BootstrapCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( + "PairingCredentialIssueError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to issue pairing credential."; + } +} -export type BootstrapCredentialError = - | BootstrapCredentialInvalidError - | BootstrapCredentialInternalError; +export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to consume bootstrap credential."; + } +} + +export const BootstrapCredentialInternalError = Schema.Union([ + ActivePairingLinksLoadError, + PairingLinkRevokeError, + PairingCredentialIssueError, + BootstrapCredentialConsumeError, +]); +export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; +export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); + +export const BootstrapCredentialError = Schema.Union([ + BootstrapCredentialInvalidError, + BootstrapCredentialInternalError, +]); +export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; +export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); export interface IssuedBootstrapCredential { readonly id: string; @@ -64,31 +152,30 @@ export type BootstrapCredentialChange = readonly id: string; }; -export interface PairingGrantStoreShape { - readonly issueOneTimeToken: (input?: { - readonly ttl?: Duration.Duration; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly label?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - BootstrapCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: (id: string) => Effect.Effect; - readonly consume: ( - credential: string, - input?: { +export class PairingGrantStore extends Context.Service< + PairingGrantStore, + { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly label?: string; readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect; -} - -export class PairingGrantStore extends Context.Service()( - "t3/auth/PairingGrantStore", -) {} + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; + readonly consume: ( + credential: string, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect; + } +>()("t3/auth/PairingGrantStore") {} interface StoredBootstrapGrant extends BootstrapGrant { readonly remainingUses: number | "unbounded"; @@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -const invalidBootstrapCredentialError = (message: string) => - new BootstrapCredentialInvalidError({ - message, - }); - -const internalBootstrapCredentialError = (message: string, cause: unknown) => - new BootstrapCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makePairingGrantStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); @@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); } - const toBootstrapCredentialError = (message: string) => (cause: unknown) => - internalBootstrapCredentialError(message, cause); - - const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( function* () { @@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } satisfies AuthPairingLink), ); }, - Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), + Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); - const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")( + const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( function* (id) { const revokedAt = yield* DateTime.now; const revoked = yield* pairingLinks.revoke({ @@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } return revoked; }, - Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), + Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); - const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( + const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", )( function* (input) { @@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); return issued; }, - Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), + Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), ); - const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( + const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { const now = yield* DateTime.now; const seededResult: ConsumeResult = yield* Ref.modify( @@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + error: new BootstrapCredentialProofKeyMismatchError({}), }, next, ]; @@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { const matching = yield* pairingLinks.getByCredential({ credential }); if (Option.isNone(matching)) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (matching.value.revokedAt !== null) { - return yield* invalidBootstrapCredentialError( - "Bootstrap credential is no longer available.", - ); + return yield* new UnavailableBootstrapCredentialError({}); } if (matching.value.consumedAt !== null) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { - return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + return yield* new ExpiredBootstrapCredentialError({}); } if ( matching.value.proofKeyThumbprint !== null && matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint ) { - return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); + return yield* new BootstrapCredentialProofKeyMismatchError({}); } - return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + return yield* new UnavailableBootstrapCredentialError({}); }, Effect.mapError((cause) => - cause._tag === "BootstrapCredentialInvalidError" || - cause._tag === "BootstrapCredentialInternalError" - ? cause - : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), ), ); - return { + return PairingGrantStore.of({ issueOneTimeToken, listActive, get streamChanges() { @@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }, revoke, consume, - } satisfies PairingGrantStoreShape; + }); }); -export const layer = Layer.effect(PairingGrantStore, make()).pipe( +export const layer = Layer.effect(PairingGrantStore, make).pipe( Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index f18e59e62930..d4411fb9f3b7 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = () => @@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); assert.include(error.message, "Failed to read secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); @@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); assert.include(error.message, "Failed to persist secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); assert.include(error.message, "Failed to remove secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 0dc4a6bb544d..5e9890c1ea28 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; -export class SecretStoreError extends Schema.TaggedErrorClass()( - "SecretStoreError", +const secretStoreErrorContext = { + resource: Schema.String, + cause: Schema.Defect(), +}; + +export class SecretStoreSecureError extends Schema.TaggedErrorClass()( + "SecretStoreSecureError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to secure ${this.resource}.`; + } +} + +export class SecretStoreReadError extends Schema.TaggedErrorClass()( + "SecretStoreReadError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...secretStoreErrorContext, }, -) {} +) { + override get message(): string { + return `Failed to read ${this.resource}.`; + } +} + +export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to create temporary path for ${this.resource}.`; + } +} + +export class SecretStorePersistError extends Schema.TaggedErrorClass()( + "SecretStorePersistError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to persist ${this.resource}.`; + } +} + +export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreRandomGenerationError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to generate random bytes for ${this.resource}.`; + } +} + +export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( + "SecretStoreConcurrentReadError", + { + resource: Schema.String, + }, +) { + override get message(): string { + return `Failed to read ${this.resource} after concurrent creation.`; + } +} + +export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( + "SecretStoreRemoveError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to remove ${this.resource}.`; + } +} + +export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( + "SecretStoreDecodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to decode ${this.resource}.`; + } +} + +export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( + "SecretStoreEncodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to encode ${this.resource}.`; + } +} + +export const SecretStoreError = Schema.Union([ + SecretStoreSecureError, + SecretStoreReadError, + SecretStoreTemporaryPathError, + SecretStorePersistError, + SecretStoreRandomGenerationError, + SecretStoreConcurrentReadError, + SecretStoreRemoveError, + SecretStoreDecodeError, + SecretStoreEncodeError, +]); +export type SecretStoreError = typeof SecretStoreError.Type; +export const isSecretStoreError = Schema.is(SecretStoreError); const isPlatformError = (value: unknown): value is PlatformError.PlatformError => Predicate.isTagged(value, "PlatformError"); export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => - isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; - -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect, SecretStoreError>; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly create: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; -} + "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; -export class ServerSecretStore extends Context.Service()( - "t3/auth/ServerSecretStore", -) {} +export class ServerSecretStore extends Context.Service< + ServerSecretStore, + { + readonly get: (name: string) => Effect.Effect, SecretStoreError>; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly create: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; + } +>()("t3/auth/ServerSecretStore") {} -export const make = Effect.fn("makeServerSecretStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + new SecretStoreSecureError({ + resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), ), @@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStoreShape["get"] = (name) => + const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, + new SecretStoreReadError({ + resource: `secret ${name}`, cause, }), ), @@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.get"), ); - const set: ServerSecretStoreShape["set"] = (name, value) => { + const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to create temporary path for secret ${name}.`, + new SecretStoreTemporaryPathError({ + resource: `secret ${name}`, cause, }), ), @@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), @@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ); }; - const create: ServerSecretStoreShape["create"] = (name, value) => { + const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); return Effect.scoped( Effect.gen(function* () { @@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), ); }; - const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( Effect.flatMap( Option.match({ @@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { crypto.randomBytes(bytes).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to generate random bytes for secret ${name}.`, + new SecretStoreRandomGenerationError({ + resource: `secret ${name}`, cause, }), ), Effect.flatMap((generated) => create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(isSecretStoreError, (error) => isSecretAlreadyExistsError(error) ? get(name).pipe( Effect.flatMap( @@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { onSome: Effect.succeed, onNone: () => Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, + new SecretStoreConcurrentReadError({ + resource: `secret ${name}`, }), ), }), @@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStoreShape["remove"] = (name) => + const remove: ServerSecretStore["Service"]["remove"] = (name) => fileSystem.remove(resolveSecretPath(name)).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreError({ - message: `Failed to remove secret ${name}.`, + new SecretStoreRemoveError({ + resource: `secret ${name}`, cause, }), ), @@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.remove"), ); - return { + return ServerSecretStore.of({ get, set, create, getOrCreateRandom, remove, - } satisfies ServerSecretStoreShape; + }); }); -export const layer = Layer.effect(ServerSecretStore, make()); +export const layer = Layer.effect(ServerSecretStore, make); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 130222408a6e..0dd5d797d196 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi const failingSessionLookupCredentialLayer = Layer.effect( SessionStore.SessionStore, - SessionStore.make(), + SessionStore.make, ).pipe( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), @@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessions = yield* SessionStore.SessionStore; const error = yield* Effect.flip(sessions.verify("not-a-session-token")); - expect(error._tag).toBe("SessionCredentialInvalidError"); + expect(error._tag).toBe("MalformedSessionTokenError"); expect(error.message).toContain("Malformed session token"); }).pipe(Effect.provide(makeSessionStoreLayer())), ); @@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(sessionError._tag).toBe("SessionCredentialInternalError"); - expect(websocketError._tag).toBe("SessionCredentialInternalError"); + expect(sessionError._tag).toBe("SessionCredentialVerificationError"); + expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index e1064c279047..18008a7d0a17 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -10,7 +10,6 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -21,7 +20,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { @@ -63,66 +62,311 @@ export type SessionCredentialChange = readonly sessionId: AuthSessionId; }; -export class SessionCredentialInvalidError extends Data.TaggedError( - "SessionCredentialInvalidError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class SessionCredentialInternalError extends Data.TaggedError( - "SessionCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; - -export interface SessionStoreShape { - readonly cookieName: string; - readonly issue: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly method?: ServerAuthSessionMethod; - readonly scopes?: ReadonlyArray; - readonly client?: AuthClientMetadata; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly verify: (token: string) => Effect.Effect; - readonly issueWebSocketToken: ( - sessionId: AuthSessionId, - input?: { - readonly ttl?: Duration.Duration; - }, - ) => Effect.Effect< - { - readonly token: string; - readonly expiresAt: DateTime.DateTime; - }, - SessionCredentialInternalError - >; - readonly verifyWebSocketToken: ( - token: string, - ) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - SessionCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeAllExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; - readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; +export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( + "MalformedSessionTokenError", + {}, +) { + override get message(): string { + return "Malformed session token."; + } +} + +export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid session token signature."; + } } -export class SessionStore extends Context.Service()( - "t3/auth/SessionStore", -) {} +export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid session token payload."; + } +} + +export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( + "SessionTokenExpiredError", + {}, +) { + override get message(): string { + return "Session token expired."; + } +} + +export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( + "UnknownSessionTokenError", + {}, +) { + override get message(): string { + return "Unknown session token."; + } +} + +export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( + "SessionTokenRevokedError", + {}, +) { + override get message(): string { + return "Session token revoked."; + } +} + +export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass()( + "InvalidSessionExpirationClaimError", + {}, +) { + override get message(): string { + return "Invalid `exp` claim"; + } +} + +export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( + "MalformedWebSocketTokenError", + {}, +) { + override get message(): string { + return "Malformed websocket token."; + } +} + +export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid websocket token signature."; + } +} + +export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid websocket token payload."; + } +} + +export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( + "WebSocketTokenExpiredError", + {}, +) { + override get message(): string { + return "Websocket token expired."; + } +} + +export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( + "UnknownWebSocketSessionError", + {}, +) { + override get message(): string { + return "Unknown websocket session."; + } +} + +export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( + "WebSocketSessionExpiredError", + {}, +) { + override get message(): string { + return "Websocket session expired."; + } +} + +export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass()( + "WebSocketSessionRevokedError", + {}, +) { + override get message(): string { + return "Websocket session revoked."; + } +} + +export const SessionCredentialInvalidError = Schema.Union([ + MalformedSessionTokenError, + InvalidSessionTokenSignatureError, + InvalidSessionTokenPayloadError, + SessionTokenExpiredError, + UnknownSessionTokenError, + SessionTokenRevokedError, + InvalidSessionExpirationClaimError, + MalformedWebSocketTokenError, + InvalidWebSocketTokenSignatureError, + InvalidWebSocketTokenPayloadError, + WebSocketTokenExpiredError, + UnknownWebSocketSessionError, + WebSocketSessionExpiredError, + WebSocketSessionRevokedError, +]); +export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; +export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); + +const sessionCredentialInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( + "SessionClaimsEncodingError", + { + operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to encode claims"; + } +} + +export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( + "SessionCredentialIssueError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session credential."; + } +} + +export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( + "SessionCredentialVerificationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify session credential."; + } +} + +export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "WebSocketTokenIssueError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( + "WebSocketTokenVerificationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify websocket token."; + } +} + +export class ActiveSessionsListError extends Schema.TaggedErrorClass()( + "ActiveSessionsListError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list active sessions."; + } +} + +export class SessionRevocationError extends Schema.TaggedErrorClass()( + "SessionRevocationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "OtherSessionsRevocationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export const SessionCredentialInternalError = Schema.Union([ + SessionClaimsEncodingError, + SessionCredentialIssueError, + SessionCredentialVerificationError, + WebSocketTokenIssueError, + WebSocketTokenVerificationError, + ActiveSessionsListError, + SessionRevocationError, + OtherSessionsRevocationError, +]); +export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; +export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); + +export const SessionCredentialError = Schema.Union([ + SessionCredentialInvalidError, + SessionCredentialInternalError, +]); +export type SessionCredentialError = typeof SessionCredentialError.Type; +export const isSessionCredentialError = Schema.is(SessionCredentialError); + +export class SessionStore extends Context.Service< + SessionStore, + { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + readonly scopes?: ReadonlyArray; + readonly client?: AuthClientMetadata; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; + readonly issueWebSocketToken: ( + sessionId: AuthSessionId, + input?: { + readonly ttl?: Duration.Duration; + }, + ) => Effect.Effect< + { + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + SessionCredentialInternalError + >; + readonly verifyWebSocketToken: ( + token: string, + ) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + } +>()("t3/auth/SessionStore") {} const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); @@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const toSessionCredentialInternalError = (message: string) => (cause: unknown) => - new SessionCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makeSessionStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); @@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); }); - const markConnected: SessionStoreShape["markConnected"] = (sessionId) => + const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const wasDisconnected = !next.has(sessionId); @@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.withSpan("SessionStore.markConnected"), ); - const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; @@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); - const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( + const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); const issuedAt = yield* DateTime.now; @@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); - const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( + const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed session token.", - }); + return yield* new MalformedSessionTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid session token signature.", - }); + return yield* new InvalidSessionTokenSignatureError({}); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid session token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Session token expired.", - }); + return yield* new SessionTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown session token.", - }); + return yield* new UnknownSessionTokenError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Session token revoked.", - }); + return yield* new SessionTokenRevokedError({}); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid `exp` claim", - }); + return yield* new InvalidSessionExpirationClaimError({}); } return { @@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify session credential.", - cause, - }), + : new SessionCredentialVerificationError({ cause }), ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); - const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( + const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", )( function* (sessionId, input) { @@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.map(base64UrlEncode), Effect.mapError( (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { expiresAt, }; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), + Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), ); - const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( + const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", )( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed websocket token.", - }); + return yield* new MalformedWebSocketTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid websocket token signature.", - }); + return yield* new InvalidWebSocketTokenSignatureError({}); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid websocket token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket token expired.", - }); + return yield* new WebSocketTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown websocket session.", - }); + return yield* new UnknownWebSocketSessionError({}); } if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session expired.", - }); + return yield* new WebSocketSessionExpiredError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session revoked.", - }); + return yield* new WebSocketSessionRevokedError({}); } return { @@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify websocket token.", - cause, - }), + : new WebSocketTokenVerificationError({ cause }), ), ); - const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")( + const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { const now = yield* DateTime.now; const connectedSessions = yield* Ref.get(connectedSessionsRef); @@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { }), ); }, - Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), + Effect.mapError((cause) => new ActiveSessionsListError({ cause })), ); - const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")( + const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; const revoked = yield* authSessions.revoke({ @@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), + Effect.mapError((cause) => new SessionRevocationError({ cause })), ); - const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", )( function* (sessionId) { @@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revokedSessionIds.length; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), + Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), ); - return { + return SessionStore.of({ cookieName, issue, verify, @@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { revokeAllExcept, markConnected, markDisconnected, - } satisfies SessionStoreShape; + }); }); -export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessions.layer), -); +export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 76898bc9463d..fa75c407b0c6 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { SecretStorePersistError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist DPoP proof.", + new SecretStorePersistError({ + resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { - const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); + const cause = storeFailure("AlreadyExists"); + const error = mapDpopReplayStoreError(cause); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + if (error._tag === "ServerAuthInvalidCredentialError") { + expect(error.cause).toBe(cause); + } }); it("reports replay-store availability failures as internal errors", () => { const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - expect(error._tag).toBe("ServerAuthInternalError"); - if (error._tag === "ServerAuthInternalError") { + expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); + if (error._tag === "ServerAuthDpopReplayStateRecordError") { expect(error.message).toBe("Failed to record DPoP proof replay state."); } }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 66cd07f9e2e3..87dc0c263e28 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { + ServerAuthDpopReplayKeyCalculationError, + ServerAuthDpopReplayStateRecordError, + ServerAuthInvalidCredentialError, + type ServerAuthInternalError, +} from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; function firstHeaderValue(value: string | undefined): string | undefined { @@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, -): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => +): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) - ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP proof replayed.", + ? new ServerAuthInvalidCredentialError({ + diagnostic: "DPoP proof replayed.", + cause: error, }) - : new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to record DPoP proof replay state.", + : new ServerAuthDpopReplayStateRecordError({ cause: error, }); @@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { - return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: result.reason, + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: result.reason, }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to calculate DPoP replay key.", + new ServerAuthDpopReplayKeyCalculationError({ cause, }), ), @@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.fail(mapDpopReplayStoreError(error)), + ), ); return result.thumbprint; }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 6e1be00209dd..71fb00b970a0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { @@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ), @@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ), ), ) .handle( @@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ) : undefined; yield* appendCredentialResponseHeaders; @@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); }, traceRelayRequest, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => + failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ), ) .handle( @@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("websocket_ticket_issuance_failed", error), ), ), @@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_credential_issuance_failed", error), ), ), @@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_links_load_failed", error), ), ), @@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_link_revoke_failed", error), ), ), @@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_sessions_load_failed", error), ), ), @@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTags({ - ServerAuthForbiddenOperationError: (error) => - failEnvironmentOperationForbidden(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - }), + Effect.catchTag("ServerAuthForbiddenOperationError", () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + ), ), ) .handle( @@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_session_revoke_failed", error), ), ), diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 5c20cd64ed32..48c44ccc48ad 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; const makeServerSecretStoreLayer = () => @@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - message: "Concurrent keypair creation won.", + new ServerSecretStore.SecretStorePersistError({ + resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it ), getOrCreateRandom: unusedSecretStoreOperation, remove: unusedSecretStoreOperation, - } satisfies ServerSecretStore.ServerSecretStoreShape; + } satisfies ServerSecretStore.ServerSecretStore["Service"]; assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "winner-private", diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index f051d8265cb7..1d0cde91bf4f 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = (message: string, cause?: unknown) => - new ServerSecretStore.SecretStoreError({ message, cause }); +const KEY_PAIR_RESOURCE = "environment signing key pair"; + +const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => + new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => + new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => + new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); if (Option.isNone(encoded)) { return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to decode environment signing key pair.", cause), - ), + Effect.mapError(keyPairDecodeError), ); return Option.some(decoded); }); const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to encode environment signing key pair.", cause), - ), + Effect.mapError(keyPairEncodeError), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? readEnvironmentKeyPair(secrets).pipe( Effect.flatMap( Option.match({ onSome: Effect.succeed, - onNone: () => - Effect.fail( - keyPairPersistenceError( - "Failed to read environment signing key pair after concurrent creation.", - ), - ), + onNone: () => Effect.fail(keyPairConcurrentReadError()), }), ), ) @@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio }); export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const existing = yield* readEnvironmentKeyPair(secrets); if (Option.isSome(existing)) { diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 58274c9d708b..ed2e5a4cf759 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist cloud replay guard.", + new ServerSecretStore.SecretStorePersistError({ + resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -40,6 +40,30 @@ function makeSecretStore( }; } +it("preserves messages surfaced by cloud 500 responses", () => { + const cause = new Error("cloud operation failed"); + + expect([ + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, + ]).toEqual([ + "Could not verify the linked cloud account.", + "Could not read the linked cloud account.", + "Cloud linked user is not installed for this environment.", + "Failed to sign cloud link JWT.", + "Cloud mint public key is not installed for this environment.", + "Cloud relay issuer is not installed for this environment.", + "Failed to sign cloud health JWT.", + "Failed to sign cloud mint JWT.", + ]); +}); + describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 71be9f376d84..fc2adca9fbc6 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -68,7 +68,7 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import * as CliState from "./CliState.ts"; +import { setCliDesiredCloudLink } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; @@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = ); const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error.cause); + failEnvironmentCloudInternalError(error.message)(error); const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( @@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? Effect.succeed(false) : Effect.fail(error), @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not verify the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause, }), ), @@ -239,19 +238,14 @@ function readInstalledCloudUserId( return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not read the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause, }), ), Effect.flatMap((bytes) => Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", - }), - ), + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud link JWT.", + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause, }), ), @@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not generate environment link proof."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), + Effect.catchTag( + "SchemaError", + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - }), ); const relayClientRequest = ( @@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* CliState.setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpointRuntime: link.endpointRuntime, }); }, + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), + ), Effect.catchTags({ CloudCliCredentialRemovalError: failCloudCliTokenManagerError, CloudCliCredentialRefreshError: failCloudCliTokenManagerError, CloudCliCredentialReadError: failCloudCliTokenManagerError, CloudCliAuthorizationError: failCloudCliTokenManagerError, CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), }), ); @@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not read environment relay configuration."), ), ); @@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); - yield* CliState.setCliDesiredCloudLink(false); + yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not remove environment relay configuration."), ), ); @@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), ), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud health JWT.", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause, }), ), @@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not answer cloud health request."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud mint JWT.", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause, }), ), @@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - }), ); export const connectHttpApiLayer = HttpApiBuilder.group( diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0528d5e523d3..ce9b498cb1f1 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index e6b26efb3ff4..40ed694723d7 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -67,15 +67,15 @@ function makeMemorySecretStore() { Effect.sync(() => { const value = values.get(name); return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], set: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], create: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], getOrCreateRandom: ((name, bytes) => Effect.sync(() => { const existing = values.get(name); @@ -85,12 +85,12 @@ function makeMemorySecretStore() { const generated = new Uint8Array(bytes); values.set(name, generated); return generated; - })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], remove: ((name) => Effect.sync(() => { values.delete(name); - })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], - } satisfies ServerSecretStore.ServerSecretStoreShape; + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], + } satisfies ServerSecretStore.ServerSecretStore["Service"]; return { store, setString: (name: string, value: string) => store.set(name, encodeSecret(value)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e76b3f63d7a6..03b609ddcfeb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, From 86db35ca6175c3340a18d151a486b77b82664ba7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:40:55 -0700 Subject: [PATCH 03/66] [codex] Structure mobile native static-check failures (#3302) Co-authored-by: codex --- scripts/mobile-native-static-check.test.ts | 18 ++++++++++++++++ scripts/mobile-native-static-check.ts | 25 ++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 scripts/mobile-native-static-check.test.ts diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts new file mode 100644 index 000000000000..9671ffbbc9fe --- /dev/null +++ b/scripts/mobile-native-static-check.test.ts @@ -0,0 +1,18 @@ +import { assert, it } from "@effect/vitest"; + +import { NativeStaticCheckCommandError } from "./mobile-native-static-check.ts"; + +it("describes failed native static-analysis commands structurally", () => { + const error = new NativeStaticCheckCommandError({ + command: "swiftlint", + args: ["lint", "--strict"], + cwd: "/repo/apps/mobile", + exitCode: 2, + }); + + assert.equal(error.command, "swiftlint"); + assert.deepStrictEqual(error.args, ["lint", "--strict"]); + assert.equal(error.cwd, "/repo/apps/mobile"); + assert.equal(error.exitCode, 2); + assert.equal(error.message, "Native static check command 'swiftlint' exited with code 2."); +}); diff --git a/scripts/mobile-native-static-check.ts b/scripts/mobile-native-static-check.ts index 4b43788a9ef1..cbdf4be2bd07 100644 --- a/scripts/mobile-native-static-check.ts +++ b/scripts/mobile-native-static-check.ts @@ -4,12 +4,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Console from "effect/Console"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import { Command } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -18,9 +18,19 @@ interface NativeStaticTool { readonly installHint: string; } -class NativeStaticCheckError extends Data.TaggedError("NativeStaticCheckError")<{ - readonly message: string; -}> {} +export class NativeStaticCheckCommandError extends Schema.TaggedErrorClass()( + "NativeStaticCheckCommandError", + { + command: Schema.String, + args: Schema.Array(Schema.String), + cwd: Schema.String, + exitCode: Schema.Int, + }, +) { + override get message(): string { + return `Native static check command '${this.command}' exited with code ${this.exitCode}.`; + } +} const tools = [ { @@ -85,8 +95,11 @@ const runCommand = Effect.fn("runCommand")(function* ( const exitCode = Number(yield* child.exitCode); if (exitCode !== 0) { - return yield* new NativeStaticCheckError({ - message: `Command exited with non-zero exit code (${exitCode})`, + return yield* new NativeStaticCheckCommandError({ + command, + args, + cwd, + exitCode, }); } }); From 20734d4a78e4acb1bc1fff8083df5f71165cc545 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:41:13 -0700 Subject: [PATCH 04/66] [codex] Structure macOS passkey signing failures (#3303) Co-authored-by: codex --- scripts/build-desktop-artifact.test.ts | 89 ++++++++++--- scripts/build-desktop-artifact.ts | 171 ++++++++++++++++++++----- 2 files changed, 213 insertions(+), 47 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b0d84bb12b5e..f8c354a85992 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -6,10 +6,15 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { + BuildScriptError, createStageWorkspaceConfig, createStagePnpmConfig, createBuildConfig, DESKTOP_ASAR_UNPACK, + InvalidMacPasskeyRpDomainError, + InvalidMacPasskeyPublishableKeyError, + isMacPasskeySigningConfigurationError, + MissingMacPasskeyProvisioningProfileError, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -214,23 +219,43 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("rejects incomplete macOS passkey signing configuration", () => { - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", - }), - /T3CODE_MACOS_PROVISIONING_PROFILE/u, - ); - assert.throws( - () => - resolveMacPasskeySigningConfiguration({ - T3CODE_APPLE_TEAM_ID: "ABC1234567", - T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", - T3CODE_CLERK_PASSKEY_RP_DOMAINS: "https://example.clerk.accounts.dev/path", - }), - /Invalid passkey RP domain/u, + const captureError = (env: Readonly>) => { + try { + resolveMacPasskeySigningConfiguration(env); + } catch (error) { + return error; + } + return assert.fail("Expected passkey signing configuration to fail."); + }; + + const missingProfileError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: "example.clerk.accounts.dev", + }); + assert.instanceOf(missingProfileError, MissingMacPasskeyProvisioningProfileError); + assert.equal( + missingProfileError.message, + "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", ); + + const unsafeDomain = + "https://domain-user:domain-secret@example.clerk.accounts.dev/path?token=query-secret"; + const invalidDomainError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PASSKEY_RP_DOMAINS: unsafeDomain, + }); + assert.instanceOf(invalidDomainError, InvalidMacPasskeyRpDomainError); + assert.equal(invalidDomainError.reason, "scheme-not-allowed"); + assert.equal(invalidDomainError.inputLength, unsafeDomain.length); + assert.equal(invalidDomainError.message, "Invalid passkey RP domain (scheme-not-allowed)."); + assert.notProperty(invalidDomainError, "domain"); + assert.notProperty(invalidDomainError, "cause"); + const serializedInvalidDomainError = JSON.stringify(invalidDomainError); + assert.notInclude(serializedInvalidDomainError, unsafeDomain); + assert.notInclude(serializedInvalidDomainError, "domain-user"); + assert.notInclude(serializedInvalidDomainError, "domain-secret"); + assert.notInclude(serializedInvalidDomainError, "query-secret"); assert.throws( () => resolveMacPasskeySigningConfiguration({ @@ -240,6 +265,38 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), /Invalid passkey RP domain/u, ); + const invalidPublishableKeyError = captureError({ + T3CODE_APPLE_TEAM_ID: "ABC1234567", + T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/t3code.provisionprofile", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_%", + }); + assert.instanceOf(invalidPublishableKeyError, InvalidMacPasskeyPublishableKeyError); + assert.ok(invalidPublishableKeyError.cause); + assert.equal(invalidPublishableKeyError.message, "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."); + assert.notProperty(invalidPublishableKeyError, "publishableKey"); + assert.notInclude(invalidPublishableKeyError.message, "pk_test_%"); + }); + + it("preserves known passkey signing configuration errors at the build boundary", () => { + const decodingCause = new Error("publishable-key-decode-failed"); + const knownError = new InvalidMacPasskeyPublishableKeyError({ cause: decodingCause }); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(knownError); + + assert.strictEqual(error, knownError); + assert.instanceOf(error, InvalidMacPasskeyPublishableKeyError); + assert.strictEqual(error.cause, decodingCause); + assert.isTrue(isMacPasskeySigningConfigurationError(error)); + }); + + it("wraps unknown passkey signing configuration defects without copying cause text", () => { + const secret = "pk_test_do-not-retain"; + const cause = new Error(secret); + const error = BuildScriptError.fromMacPasskeySigningConfiguration(cause); + + assert.instanceOf(error, BuildScriptError); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to resolve macOS passkey signing configuration."); + assert.notInclude(error.message, secret); }); it.effect("adds passkey entitlements and both renderer protocols to signed macOS builds", () => diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2d708057e381..6f13783f2d1c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -126,10 +126,21 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); -class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ +export class BuildScriptError extends Data.TaggedError("BuildScriptError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + static fromMacPasskeySigningConfiguration( + cause: unknown, + ): MacPasskeySigningConfigurationError | BuildScriptError { + return isMacPasskeySigningConfigurationError(cause) + ? cause + : new BuildScriptError({ + message: "Failed to resolve macOS passkey signing configuration.", + cause, + }); + } +} const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => stream.pipe( @@ -306,26 +317,128 @@ export interface MacPasskeySigningConfiguration { readonly provisioningProfilePath: string; } +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedErrorClass()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +export class MissingMacPasskeyProvisioningProfileError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyProvisioningProfileError", + {}, +) { + override get message(): string { + return "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile."; + } +} + +export class MissingMacPasskeyDomainConfigurationError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyDomainConfigurationError", + {}, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds."; + } +} + +export class InvalidMacPasskeyPublishableKeyError extends Schema.TaggedErrorClass()( + "InvalidMacPasskeyPublishableKeyError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "T3CODE_CLERK_PUBLISHABLE_KEY is invalid."; + } +} + +export class MissingMacPasskeyRpDomainError extends Schema.TaggedErrorClass()( + "MissingMacPasskeyRpDomainError", + {}, +) { + override get message(): string { + return "At least one Clerk passkey RP domain is required."; + } +} + +export const MacPasskeySigningConfigurationError = Schema.Union([ + InvalidMacPasskeyRpDomainError, + InvalidAppleTeamIdError, + MissingMacPasskeyProvisioningProfileError, + MissingMacPasskeyDomainConfigurationError, + InvalidMacPasskeyPublishableKeyError, + MissingMacPasskeyRpDomainError, +]); +export type MacPasskeySigningConfigurationError = typeof MacPasskeySigningConfigurationError.Type; +export const isMacPasskeySigningConfigurationError = Schema.is(MacPasskeySigningConfigurationError); + function normalizePasskeyRpDomain(value: string): string { const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + let parsed: URL; try { parsed = new URL(`https://${normalized}`); - } catch { - throw new Error(`Invalid passkey RP domain: ${value}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); } - if ( - normalized.length === 0 || - parsed.host !== normalized || - parsed.username.length > 0 || - parsed.password.length > 0 || - parsed.port.length > 0 || - parsed.pathname !== "/" || - parsed.search.length > 0 || - parsed.hash.length > 0 - ) { - throw new Error(`Invalid passkey RP domain: ${value}`); + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); } return parsed.hostname; @@ -336,14 +449,12 @@ export function resolveMacPasskeySigningConfiguration( ): MacPasskeySigningConfiguration { const teamId = env.T3CODE_APPLE_TEAM_ID?.trim().toUpperCase() ?? ""; if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { - throw new Error("T3CODE_APPLE_TEAM_ID must be a 10-character Apple Developer Team ID."); + throw new InvalidAppleTeamIdError({ teamId }); } const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; if (provisioningProfilePath.length === 0) { - throw new Error( - "T3CODE_MACOS_PROVISIONING_PROFILE must point to an Associated Domains provisioning profile.", - ); + throw new MissingMacPasskeyProvisioningProfileError(); } const configuredRpDomains = env.T3CODE_CLERK_PASSKEY_RP_DOMAINS?.trim(); @@ -353,18 +464,20 @@ export function resolveMacPasskeySigningConfiguration( } else { const publishableKey = env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim(); if (!publishableKey) { - throw new Error( - "T3CODE_CLERK_PUBLISHABLE_KEY or T3CODE_CLERK_PASSKEY_RP_DOMAINS is required for signed macOS passkey builds.", - ); + throw new MissingMacPasskeyDomainConfigurationError(); } - rpDomains = [ - normalizePasskeyRpDomain(clerkFrontendApiHostnameFromPublishableKey(publishableKey)), - ]; + let hostname: string; + try { + hostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); + } catch (cause) { + throw new InvalidMacPasskeyPublishableKeyError({ cause }); + } + rpDomains = [normalizePasskeyRpDomain(hostname)]; } const uniqueRpDomains = [...new Set(rpDomains)]; if (uniqueRpDomains.length === 0) { - throw new Error("At least one Clerk passkey RP domain is required."); + throw new MissingMacPasskeyRpDomainError(); } return { @@ -1150,11 +1263,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.platform === "mac" && options.signed ? yield* Effect.try({ try: () => resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot })), - catch: (cause) => - new BuildScriptError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), + catch: BuildScriptError.fromMacPasskeySigningConfiguration, }) : undefined; const macPasskeySigning = configuredMacPasskeySigning From 515303edf2f83ab6ba15ebdae01ea2d5765756fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:41:55 -0700 Subject: [PATCH 05/66] [codex] Preserve desktop user-data probe failures (#3304) Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 35 ++++++++++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 28 ++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 7c4c06eb6161..3c95b266bc18 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import type * as Electron from "electron"; @@ -105,6 +106,7 @@ const withIdentity = ( readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; readonly legacyPathExists?: boolean; + readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; } = {}, @@ -121,7 +123,11 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + input.legacyPathProbeError + ? Effect.fail(input.legacyPathProbeError) + : Effect.succeed( + input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), + ), readFileString: () => Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), }), @@ -147,6 +153,33 @@ describe("DesktopAppIdentity", () => { ), ); + it.effect("preserves failures while inspecting the legacy userData path", () => { + const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + description: "permission denied", + pathOrDescriptor: legacyPath, + }); + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); + + assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); + assert.equal(error.legacyPath, legacyPath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, + ); + }), + { legacyPathProbeError: cause }, + ); + }); + it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { setAboutPanelOptions: [], diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 2664581b1872..385e694338dd 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,10 +18,22 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); +export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( + "DesktopUserDataPathResolutionError", + { + legacyPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; + } +} + export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -33,7 +45,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -83,9 +95,15 @@ const make = Effect.gen(function* () { environment.appDataDirectory, environment.legacyUserDataDirName, ); - const legacyPathExists = yield* fileSystem - .exists(legacyPath) - .pipe(Effect.orElseSucceed(() => false)); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); return legacyPathExists ? legacyPath : environment.path.join(environment.appDataDirectory, environment.userDataDirName); From b4fe8faa1d59aa602a04737aa4054ee8acb62193 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:42:38 -0700 Subject: [PATCH 06/66] [codex] Structure relay activity-row persistence errors (#3305) Co-authored-by: codex --- .../agentActivity/AgentActivityRows.test.ts | 84 ++++++++++++ .../src/agentActivity/AgentActivityRows.ts | 123 ++++++++++++------ .../agentActivity/MobileRegistrations.test.ts | 1 + 3 files changed, 167 insertions(+), 41 deletions(-) create mode 100644 infra/relay/src/agentActivity/AgentActivityRows.test.ts diff --git a/infra/relay/src/agentActivity/AgentActivityRows.test.ts b/infra/relay/src/agentActivity/AgentActivityRows.test.ts new file mode 100644 index 000000000000..be976d16bbbc --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityRows.test.ts @@ -0,0 +1,84 @@ +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as RelayDb from "../db.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; + +const state: RelayAgentActivityState = { + environmentId: "env-1" as RelayAgentActivityState["environmentId"], + threadId: "thread-1" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "2026-06-20T00:00:00.000Z", + deepLink: "/threads/env-1/thread-1", +}; + +describe("AgentActivityRows", () => { + it.effect("preserves activity context on persistence failures", () => { + const cause = new Error("database unavailable"); + const failingDb = { + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => Effect.fail(cause), + }), + }), + delete: () => ({ + where: () => Effect.fail(cause), + }), + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + orderBy: () => Effect.fail(cause), + }), + }), + }), + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; + + const upsertError = yield* rows + .upsert({ environmentPublicKey: "public-key", state }) + .pipe(Effect.flip); + expect(upsertError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(upsertError.message).toBe( + "Failed to persist agent activity state for environment env-1, thread thread-1.", + ); + + const deleteError = yield* rows + .remove({ + environmentId: "env-1", + environmentPublicKey: "public-key", + threadId: "thread-1", + }) + .pipe(Effect.flip); + expect(deleteError).toMatchObject({ + environmentId: "env-1", + threadId: "thread-1", + cause, + }); + expect(deleteError.message).toBe( + "Failed to delete agent activity state for environment env-1, thread thread-1.", + ); + + const listError = yield* rows.listForUser({ userId: "user-2" }).pipe(Effect.flip); + expect(listError).toMatchObject({ userId: "user-2", cause }); + expect(listError.message).toBe("Failed to list agent activity state for user user-2."); + }).pipe( + Effect.provide( + AgentActivityRows.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, failingDb))), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 7f19378633ff..7e1a8c50f1b0 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -14,28 +14,39 @@ import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/sc export class AgentActivityRowUpsertPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowUpsertPersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to persist agent activity state"; + return `Failed to persist agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowDeletePersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowDeletePersistenceError", - { cause: Schema.Defect() }, + { + environmentId: Schema.String, + threadId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to delete agent activity state"; + return `Failed to delete agent activity state for environment ${this.environmentId}, thread ${this.threadId}.`; } } export class AgentActivityRowListPersistenceError extends Schema.TaggedErrorClass()( "AgentActivityRowListPersistenceError", - { cause: Schema.Defect() }, + { + userId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to list agent activity state"; + return `Failed to list agent activity state for user ${this.userId}.`; } } @@ -75,41 +86,56 @@ export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; return AgentActivityRows.of({ - upsert: Effect.fn("relay.agent_activity_rows.upsert")( - function* (input) { - yield* Effect.annotateCurrentSpan({ - "relay.environment_id": input.state.environmentId, - "relay.thread_id": input.state.threadId, - }); - const now = yield* DateTime.now; - const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( - Effect.flatMap(decodeJsonString), - Effect.map(Function.cast), - ); - yield* db - .insert(relayAgentActivityRows) - .values({ - environmentId: input.state.environmentId, - environmentPublicKey: input.environmentPublicKey, - threadId: input.state.threadId, + upsert: Effect.fn("relay.agent_activity_rows.upsert")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.state.environmentId, + "relay.thread_id": input.state.threadId, + }); + const now = yield* DateTime.now; + const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(Function.cast), + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + yield* db + .insert(relayAgentActivityRows) + .values({ + environmentId: input.state.environmentId, + environmentPublicKey: input.environmentPublicKey, + threadId: input.state.threadId, + stateJson, + updatedAt: input.state.updatedAt, + createdAt: DateTime.formatIso(now), + }) + .onConflictDoUpdate({ + target: [ + relayAgentActivityRows.environmentId, + relayAgentActivityRows.environmentPublicKey, + relayAgentActivityRows.threadId, + ], + set: { stateJson, updatedAt: input.state.updatedAt, - createdAt: DateTime.formatIso(now), - }) - .onConflictDoUpdate({ - target: [ - relayAgentActivityRows.environmentId, - relayAgentActivityRows.environmentPublicKey, - relayAgentActivityRows.threadId, - ], - set: { - stateJson, - updatedAt: input.state.updatedAt, - }, - }); - }, - Effect.mapError((cause) => new AgentActivityRowUpsertPersistenceError({ cause })), - ), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowUpsertPersistenceError({ + environmentId: input.state.environmentId, + threadId: input.state.threadId, + cause, + }), + ), + ); + }), remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { yield* Effect.annotateCurrentSpan({ @@ -125,7 +151,16 @@ export const make = Effect.gen(function* () { eq(relayAgentActivityRows.threadId, input.threadId), ), ) - .pipe(Effect.mapError((cause) => new AgentActivityRowDeletePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => + new AgentActivityRowDeletePersistenceError({ + environmentId: input.environmentId, + threadId: input.threadId, + cause, + }), + ), + ); }), listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { @@ -159,7 +194,13 @@ export const make = Effect.gen(function* () { Effect.map((rows) => rows.flatMap((row) => Option.toArray(decodeRelayAgentActivityStateJson(row))), ), - Effect.mapError((cause) => new AgentActivityRowListPersistenceError({ cause })), + Effect.mapError( + (cause) => + new AgentActivityRowListPersistenceError({ + userId: input.userId, + cause, + }), + ), ); }), }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index eed330dd5894..17a9c7bd417e 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -249,6 +249,7 @@ describe("MobileRegistrations", () => { replayForLiveActivityRegistration: () => Effect.fail( new AgentActivityRows.AgentActivityRowListPersistenceError({ + userId: "dev:julius", cause: "replay failed", }), ), From 8c3755aeccd5fc90fe66f6ca4d776c0e370fe46e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:54:12 -0700 Subject: [PATCH 07/66] [codex] Structure bootstrap errors (#3256) Co-authored-by: codex --- apps/server/src/bootstrap.test.ts | 107 ++++++++++++++++++++++++++++-- apps/server/src/bootstrap.ts | 98 +++++++++++++++++++++------ 2 files changed, 180 insertions(+), 25 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 84cf85c3213f..05155f32ec4c 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -3,7 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Duration from "effect/Duration"; @@ -13,10 +13,19 @@ import * as TestClock from "effect/testing/TestClock"; import { vi } from "vite-plus/test"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { readBootstrapEnvelope } from "./bootstrap.ts"; +import { + BootstrapEnvelopeDecodeError, + BootstrapFdStatError, + BootstrapInputStreamOpenError, + readBootstrapEnvelope, +} from "./bootstrap.ts"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); +const openSyncInterceptor = vi.hoisted(() => ({ + failPath: null as string | null, + errorCode: "ENXIO", +})); +const fstatSyncInterceptor = vi.hoisted(() => ({ failFd: null as number | null })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -29,12 +38,20 @@ vi.mock("node:fs", async (importOriginal) => { filePath === openSyncInterceptor.failPath && flags === "r" ) { - const error = new Error("no such device or address"); - Object.assign(error, { code: "ENXIO" }); + const error = new Error(`open failed with ${openSyncInterceptor.errorCode}`); + Object.assign(error, { code: openSyncInterceptor.errorCode }); throw error; } return (actual.openSync as (...a: typeof args) => number)(...args); }, + fstatSync: (...args: Parameters) => { + if (args[0] === fstatSyncInterceptor.failFd) { + const error = new Error("permission denied"); + Object.assign(error, { code: "EACCES" }); + throw error; + } + return (actual.fstatSync as (...a: typeof args) => NodeFS.Stats)(...args); + }, }; }); @@ -94,6 +111,39 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd path, platform, and cause when opening the input stream fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const fdPath = `/proc/self/fd/${fd}`; + + openSyncInterceptor.failPath = fdPath; + openSyncInterceptor.errorCode = "EIO"; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.provideService(HostProcessPlatform, "linux"), Effect.flip); + + assert.instanceOf(error, BootstrapInputStreamOpenError); + assert.equal(error.fd, fd); + assert.equal(error.platform, "linux"); + assert.equal(error.fdPath, fdPath); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EIO"); + assert.equal( + error.message, + `Failed to open bootstrap input stream for file descriptor ${fd} via '${fdPath}' on 'linux'.`, + ); + } finally { + openSyncInterceptor.failPath = null; + openSyncInterceptor.errorCode = "ENXIO"; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { const fd = NodeFS.openSync("/dev/null", "r"); @@ -104,6 +154,53 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("preserves fd and cause when stat fails for a non-availability reason", () => + Effect.gen(function* () { + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync("/dev/null", "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + + fstatSyncInterceptor.failFd = fd; + try { + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapFdStatError); + assert.equal(error.fd, fd); + assert.equal((error.cause as NodeJS.ErrnoException).code, "EACCES"); + assert.equal(error.message, `Failed to stat bootstrap file descriptor ${fd}.`); + } finally { + fstatSyncInterceptor.failFd = null; + } + }), + ); + + it.effect("preserves fd and schema cause when decoding the envelope fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, '{"mode":42}\n'); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NodeFS.closeSync(fd)), + ); + const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.flip); + + assert.instanceOf(error, BootstrapEnvelopeDecodeError); + assert.equal(error.fd, fd); + assert.isDefined(error.cause); + assert.equal( + error.message, + `Failed to decode bootstrap envelope from file descriptor ${fd}.`, + ); + }), + ); + it.effect("returns none when the bootstrap read times out before any value arrives", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 1114ad8af90d..0f2a5a436a37 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -4,7 +4,6 @@ import * as NodeNet from "node:net"; import * as NodeReadline from "node:readline"; import type * as NodeStream from "node:stream"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; @@ -13,10 +12,64 @@ import * as Schema from "effect/Schema"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -class BootstrapError extends Data.TaggedError("BootstrapError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BootstrapFdStatError extends Schema.TaggedErrorClass()( + "BootstrapFdStatError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat bootstrap file descriptor ${this.fd}.`; + } +} + +export class BootstrapInputStreamOpenError extends Schema.TaggedErrorClass()( + "BootstrapInputStreamOpenError", + { + fd: Schema.Number, + platform: Schema.String, + fdPath: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const path = this.fdPath === undefined ? "" : ` via '${this.fdPath}'`; + return `Failed to open bootstrap input stream for file descriptor ${this.fd}${path} on '${this.platform}'.`; + } +} + +export class BootstrapEnvelopeReadError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeReadError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export class BootstrapEnvelopeDecodeError extends Schema.TaggedErrorClass()( + "BootstrapEnvelopeDecodeError", + { + fd: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode bootstrap envelope from file descriptor ${this.fd}.`; + } +} + +export const BootstrapError = Schema.Union([ + BootstrapFdStatError, + BootstrapInputStreamOpenError, + BootstrapEnvelopeReadError, + BootstrapEnvelopeDecodeError, +]); +export type BootstrapError = typeof BootstrapError.Type; export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( schema: Schema.Codec, @@ -32,7 +85,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function const timeoutMs = options?.timeoutMs ?? 1000; - return yield* Effect.callback, BootstrapError>((resume) => { + return yield* Effect.callback< + Option.Option, + BootstrapEnvelopeReadError | BootstrapEnvelopeDecodeError + >((resume) => { const input = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity, @@ -53,8 +109,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } resume( Effect.fail( - new BootstrapError({ - message: "Failed to read bootstrap envelope.", + new BootstrapEnvelopeReadError({ + fd, cause: error, }), ), @@ -68,8 +124,8 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function } else { resume( Effect.fail( - new BootstrapError({ - message: "Failed to decode bootstrap envelope.", + new BootstrapEnvelopeDecodeError({ + fd, cause: parsed.failure, }), ), @@ -98,24 +154,24 @@ const isFdReady = (fd: number) => Effect.try({ try: () => NodeFS.fstatSync(fd), catch: (error) => - new BootstrapError({ - message: "Failed to stat bootstrap fd.", + new BootstrapFdStatError({ + fd, cause: error, }), }).pipe( Effect.as(true), - Effect.catchIf( - (error) => isUnavailableBootstrapFdError(error.cause), - () => Effect.succeed(false), - ), + Effect.catchTags({ + BootstrapFdStatError: (error) => + isUnavailableBootstrapFdError(error.cause) ? Effect.succeed(false) : Effect.fail(error), + }), ); const makeBootstrapInputStream = (fd: number) => Effect.gen(function* () { const platform = yield* HostProcessPlatform; - return yield* Effect.try({ + const fdPath = resolveFdPath(fd, platform); + return yield* Effect.try({ try: () => { - const fdPath = resolveFdPath(fd, platform); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); } @@ -139,8 +195,10 @@ const makeBootstrapInputStream = (fd: number) => } }, catch: (error) => - new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + new BootstrapInputStreamOpenError({ + fd, + platform, + ...(fdPath === undefined ? {} : { fdPath }), cause: error, }), }); From e55dd0067dde55770a4f7f7d1720615fcfd56389 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:54:56 -0700 Subject: [PATCH 08/66] [codex] Structure preview session key errors (#3388) Co-authored-by: codex --- .../src/components/preview/usePreviewSession.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts index e5444bdd22d9..2a82f6275742 100644 --- a/apps/web/src/components/preview/usePreviewSession.ts +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -4,6 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { @@ -13,10 +14,19 @@ import { } from "~/previewStateStore"; import { previewEnvironment } from "~/state/preview"; +class PreviewSessionThreadKeyParseError extends Schema.TaggedErrorClass()( + "PreviewSessionThreadKeyParseError", + { threadKey: Schema.String }, +) { + override get message(): string { + return `Invalid scoped preview thread key: ${this.threadKey}`; + } +} + const previewSessionSyncAtom = Atom.family((threadKey: string) => { const threadRef = parseScopedThreadKey(threadKey); - if (!threadRef) { - throw new Error(`Invalid scoped preview thread key: ${threadKey}`); + if (threadRef === null) { + throw new PreviewSessionThreadKeyParseError({ threadKey }); } const sessionsAtom = previewEnvironment.list({ From d87ec967bf5ef884486479e887e74307276d3e74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:56:12 -0700 Subject: [PATCH 09/66] [codex] Structure empty mobile pairing payload errors (#3372) Co-authored-by: codex --- apps/mobile/src/features/connection/pairing.test.ts | 9 +++++++-- apps/mobile/src/features/connection/pairing.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 028c46c1ce53..18b6c71a293a 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { extractPairingUrlFromQrPayload, parsePairingUrl } from "./pairing"; +import { + extractPairingUrlFromQrPayload, + PairingQrPayloadEmptyError, + parsePairingUrl, +} from "./pairing"; describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { @@ -18,7 +22,8 @@ describe("extractPairingUrlFromQrPayload", () => { }); it("rejects empty qr payloads", () => { - expect(() => extractPairingUrlFromQrPayload(" ")).toThrow( + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); + expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( "Scanned QR code did not contain a pairing URL.", ); }); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index f7362900b0cb..910efa7f2565 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,7 +1,17 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( + "PairingQrPayloadEmptyError", + {}, +) { + override get message(): string { + return "Scanned QR code did not contain a pairing URL."; + } +} + export function buildPairingUrl(host: string, code: string): string { const h = host.trim(); const c = code.trim(); @@ -48,7 +58,7 @@ export function parsePairingUrl(url: string): { host: string; code: string } { export function extractPairingUrlFromQrPayload(payload: string): string { const trimmed = payload.trim(); if (!trimmed) { - throw new Error("Scanned QR code did not contain a pairing URL."); + throw new PairingQrPayloadEmptyError({}); } try { From 06752526b3354a560d647251676eb9d5c8a50e82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:57:33 -0700 Subject: [PATCH 10/66] [codex] Structure unavailable Bun PTY operations (#3394) Co-authored-by: codex --- apps/server/src/terminal/BunPtyAdapter.test.ts | 17 +++++++++++++++++ apps/server/src/terminal/BunPtyAdapter.ts | 17 +++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/terminal/BunPtyAdapter.test.ts diff --git a/apps/server/src/terminal/BunPtyAdapter.test.ts b/apps/server/src/terminal/BunPtyAdapter.test.ts new file mode 100644 index 000000000000..39e811db3a90 --- /dev/null +++ b/apps/server/src/terminal/BunPtyAdapter.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "@effect/vitest"; + +import { BunPtyOperationUnavailableError } from "./BunPtyAdapter.ts"; + +it("describes unavailable Bun PTY operations structurally", () => { + const error = new BunPtyOperationUnavailableError({ + operation: "resize", + pid: 42, + }); + + expect(error).toMatchObject({ + _tag: "BunPtyOperationUnavailableError", + operation: "resize", + pid: 42, + }); + expect(error.message).toBe("Bun PTY resize is unavailable for process 42."); +}); diff --git a/apps/server/src/terminal/BunPtyAdapter.ts b/apps/server/src/terminal/BunPtyAdapter.ts index 045da058cf50..5d7a44a1071c 100644 --- a/apps/server/src/terminal/BunPtyAdapter.ts +++ b/apps/server/src/terminal/BunPtyAdapter.ts @@ -2,10 +2,23 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as PtyAdapter from "./PtyAdapter.ts"; +export class BunPtyOperationUnavailableError extends Schema.TaggedErrorClass()( + "BunPtyOperationUnavailableError", + { + operation: Schema.Literals(["write", "resize"]), + pid: Schema.Number, + }, +) { + override get message(): string { + return `Bun PTY ${this.operation} is unavailable for process ${this.pid}.`; + } +} + class BunPtyProcess implements PtyAdapter.PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); @@ -33,14 +46,14 @@ class BunPtyProcess implements PtyAdapter.PtyProcess { write(data: string): void { if (!this.process.terminal) { - throw new Error("Bun PTY terminal handle is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "write", pid: this.pid }); } this.process.terminal.write(data); } resize(cols: number, rows: number): void { if (!this.process.terminal?.resize) { - throw new Error("Bun PTY resize is unavailable"); + throw new BunPtyOperationUnavailableError({ operation: "resize", pid: this.pid }); } this.process.terminal.resize(cols, rows); } From 7a8bab5d9df80e06782033d1ee252400e6f048b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:58:03 -0700 Subject: [PATCH 11/66] [codex] Keep PTY spawn errors structural (#3325) Co-authored-by: codex --- apps/server/src/terminal/PtyAdapter.test.ts | 34 +++++++++++++++++++++ apps/server/src/terminal/PtyAdapter.ts | 4 +-- 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/terminal/PtyAdapter.test.ts diff --git a/apps/server/src/terminal/PtyAdapter.test.ts b/apps/server/src/terminal/PtyAdapter.test.ts new file mode 100644 index 000000000000..f4ac9516537d --- /dev/null +++ b/apps/server/src/terminal/PtyAdapter.test.ts @@ -0,0 +1,34 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import * as PtyAdapter from "./PtyAdapter.ts"; + +const isPtySpawnError = Schema.is(PtyAdapter.PtySpawnError); + +describe("PtySpawnError", () => { + it("derives messages from structural context while preserving the full cause chain", () => { + const spawnCause = new Error("spawn /bin/zsh ENOENT"); + const adapterError = new PtyAdapter.PtySpawnError({ + adapter: "node-pty", + shell: "/bin/zsh", + cause: spawnCause, + }); + const managerError = new PtyAdapter.PtySpawnError({ + adapter: "terminal-manager", + attemptedShells: ["/bin/zsh -o nopromptsp", "/bin/bash"], + cause: adapterError, + }); + + assert(isPtySpawnError(managerError)); + assert.strictEqual( + managerError.message, + "Failed to spawn PTY process with terminal-manager. Tried shells: /bin/zsh -o nopromptsp, /bin/bash.", + ); + assert.strictEqual( + adapterError.message, + "Failed to spawn PTY process '/bin/zsh' with node-pty.", + ); + assert.strictEqual(managerError.cause, adapterError); + assert.strictEqual(adapterError.cause, spawnCause); + }); +}); diff --git a/apps/server/src/terminal/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts index dafb6f12f4fe..67147035bb5d 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -25,9 +25,7 @@ export class PtySpawnError extends Schema.TaggedErrorClass()("Pty this.attemptedShells === undefined || this.attemptedShells.length === 0 ? "" : ` Tried shells: ${this.attemptedShells.join(", ")}.`; - const causeMessage = - this.cause instanceof Error && this.cause.message.length > 0 ? ` ${this.cause.message}` : ""; - return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}${causeMessage}`; + return `Failed to spawn PTY process${shell} with ${this.adapter}.${attemptedShells}`; } } From f7867addbe18ebb55c916041dc168aa4b8f39335 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:58:39 -0700 Subject: [PATCH 12/66] [codex] Structure mobile notification setting failures (#3391) Co-authored-by: codex --- .../liveActivityPreferences.ts | 15 +++++++++- .../notificationPermissions.ts | 29 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 8f73ffdf65ea..932376e8bce2 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; @@ -7,6 +8,18 @@ import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( + "LiveActivityPreferenceSaveError", + { + enabled: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; + } +} + export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; @@ -15,7 +28,7 @@ export function setLiveActivityUpdatesEnabled(input: { return Effect.gen(function* () { yield* Effect.tryPromise({ try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (error) => error, + catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), }); yield* refreshAgentAwarenessRegistration(); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index ce8dfddf3d21..dc275774a500 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -1,5 +1,6 @@ import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; export type NotificationPermissionResult = @@ -7,9 +8,31 @@ export type NotificationPermissionResult = | { readonly type: "granted" } | { readonly type: "denied"; readonly canAskAgain: boolean }; +export class NotificationPermissionReadError extends Schema.TaggedErrorClass()( + "NotificationPermissionReadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to read notification permissions on iOS."; + } +} + +export class NotificationPermissionRequestError extends Schema.TaggedErrorClass()( + "NotificationPermissionRequestError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to request notification permissions on iOS."; + } +} + export const requestAgentNotificationPermission: Effect.Effect< NotificationPermissionResult, - unknown + NotificationPermissionReadError | NotificationPermissionRequestError > = Effect.gen(function* () { if (Platform.OS !== "ios") { return { type: "unsupported" }; @@ -17,7 +40,7 @@ export const requestAgentNotificationPermission: Effect.Effect< const existing = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => new NotificationPermissionReadError({ cause }), }); if (existing.granted) { return { type: "granted" }; @@ -36,7 +59,7 @@ export const requestAgentNotificationPermission: Effect.Effect< allowSound: true, }, }), - catch: (error) => error, + catch: (cause) => new NotificationPermissionRequestError({ cause }), }); return requested.granted ? { type: "granted" } From bf1a6501c7ada5822f0c68e8e5a0aa6bdbb9c1b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:59:10 -0700 Subject: [PATCH 13/66] [codex] Preserve review path resolution failures (#3357) Co-authored-by: codex --- apps/server/src/review/ReviewService.test.ts | 24 ++++++++++++++++++++ apps/server/src/review/ReviewService.ts | 20 ++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index eb8758b12829..839eb73b2bb7 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -73,4 +74,27 @@ describe("ReviewService", () => { assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("preserves unexpected path-resolution failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const invalidCwd = `${workspaceRoot}\0invalid`; + const detectCalls: Array<{ readonly cwd: string }> = []; + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: invalidCwd }).pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + if (error._tag !== "VcsRepositoryDetectionError") return; + assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); + assert.strictEqual(error.cwd, invalidCwd); + assert.match(error.detail, /Failed to resolve a path/); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.deepStrictEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 3f222bd520f6..db1dc5bc8d2f 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -33,8 +33,24 @@ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; - const canonicalizePath = (value: string) => - fileSystem.realPath(path.resolve(value)).pipe(Effect.orElseSucceed(() => path.resolve(value))); + const canonicalizePath = (value: string) => { + const resolvedPath = path.resolve(value); + return fileSystem.realPath(resolvedPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(resolvedPath) + : Effect.fail( + new VcsRepositoryDetectionError({ + operation: "ReviewService.assertWorkspaceBoundCwd.canonicalizePath", + cwd: resolvedPath, + detail: "Failed to resolve a path while validating the review workspace.", + cause, + }), + ), + }), + ); + }; const isWithinRoot = (candidate: string, root: string) => { const relative = path.relative(root, candidate); From 71608142c27136a5ba0551fe9ed52ceeaad22ee4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:59:54 -0700 Subject: [PATCH 14/66] [codex] Split preferred editor precondition errors (#3324) Co-authored-by: codex --- apps/web/src/editorPreferences.ts | 45 ++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 32bdf42a807d..d691ddb31535 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,11 +1,11 @@ -import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts"; +import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts"; import { mapAtomCommandResult, type AtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useCallback, useMemo } from "react"; @@ -14,11 +14,29 @@ import { useAtomCommand } from "./state/use-atom-command"; const LAST_EDITOR_KEY = "t3code:last-editor"; -export class PreferredEditorUnavailableError extends Data.TaggedError( +export class PreferredEditorEnvironmentRequiredError extends Schema.TaggedErrorClass()( + "PreferredEditorEnvironmentRequiredError", + { + targetPath: Schema.String, + }, +) { + override get message(): string { + return `Cannot open ${this.targetPath} because no environment is selected.`; + } +} + +export class PreferredEditorUnavailableError extends Schema.TaggedErrorClass()( "PreferredEditorUnavailableError", -)<{ - readonly message: string; -}> {} + { + environmentId: EnvironmentId, + targetPath: Schema.String, + availableEditorIds: Schema.Array(EditorId), + }, +) { + override get message(): string { + return `No available editor can open ${this.targetPath} in environment ${this.environmentId}.`; + } +} export function usePreferredEditor(availableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); @@ -55,13 +73,18 @@ export function useOpenInPreferredEditor( async ( targetPath: string, ): Promise< - AtomCommandResult + AtomCommandResult< + EditorId, + | OpenInEditorError + | PreferredEditorEnvironmentRequiredError + | PreferredEditorUnavailableError + > > => { if (environmentId === null) { return AsyncResult.failure( Cause.fail( - new PreferredEditorUnavailableError({ - message: "No environment is selected.", + new PreferredEditorEnvironmentRequiredError({ + targetPath, }), ), ); @@ -71,7 +94,9 @@ export function useOpenInPreferredEditor( return AsyncResult.failure( Cause.fail( new PreferredEditorUnavailableError({ - message: "No available editors found.", + environmentId, + targetPath, + availableEditorIds: availableEditors, }), ), ); From cc69aef4dd140703762282ea207e0bcae7f9d9a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:00:42 -0700 Subject: [PATCH 15/66] [codex] Structure relay domain label errors (#3347) Co-authored-by: codex --- infra/relay/src/deploymentConfig.test.ts | 27 ++++++++++++++++++++++++ infra/relay/src/deploymentConfig.ts | 20 +++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index d7940b803188..44c7627a4daf 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { managedEndpointDigestInput, @@ -7,11 +8,14 @@ import { isManagedEndpointHostname, managedEndpointTunnelName, relayOwnsManagedEndpointZone, + RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, relayStageSlug, } from "./deploymentConfig.ts"; +const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); + describe("relayStageSlug", () => { it("matches Alchemy physical-name sanitization for default developer stages", () => { expect(relayStageSlug("dev_julius")).toBe("dev-julius"); @@ -28,6 +32,29 @@ describe("relayPublicDomainForStage", () => { "relay-dev-julius.example.com", ); }); + + it("reports the stage and derived DNS label when the label is too long", () => { + const stage = `dev_${"x".repeat(60)}`; + let error: unknown; + + try { + relayPublicDomainForStage(stage, "example.com"); + } catch (cause) { + error = cause; + } + + if (!isRelayPublicDomainLabelTooLongError(error)) { + throw error; + } + expect(error).toMatchObject({ + stage, + label: `relay-dev-${"x".repeat(60)}`, + maxLength: 63, + }); + expect(error.message).toBe( + `Relay stage '${stage}' produces custom domain label 'relay-dev-${"x".repeat(60)}' (70 characters), exceeding the DNS label limit of 63.`, + ); + }); }); describe("relayOwnsManagedEndpointZone", () => { diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fbb130548220..fe9d37b29988 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -1,10 +1,24 @@ import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Schema from "effect/Schema"; const DNS_LABEL_MAX_LENGTH = 63; const MANAGED_ENDPOINT_HASH_LENGTH = 16; const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint"; export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod"; +export class RelayPublicDomainLabelTooLongError extends Schema.TaggedErrorClass()( + "RelayPublicDomainLabelTooLongError", + { + stage: Schema.String, + label: Schema.String, + maxLength: Schema.Number, + }, +) { + override get message(): string { + return `Relay stage '${this.stage}' produces custom domain label '${this.label}' (${this.label.length} characters), exceeding the DNS label limit of ${this.maxLength}.`; + } +} + function normalizeZoneName(zoneName: string): string { return zoneName .trim() @@ -62,7 +76,11 @@ export function relayPublicDomainForStage(stage: string, zoneName: string): stri const stageSlug = relayStageSlug(stage); const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; if (relayLabel.length > DNS_LABEL_MAX_LENGTH) { - throw new Error(`Relay stage is too long for a custom domain: ${stage}`); + throw new RelayPublicDomainLabelTooLongError({ + stage, + label: relayLabel, + maxLength: DNS_LABEL_MAX_LENGTH, + }); } return `${relayLabel}.${normalizeZoneName(zoneName)}`; } From 40d14647db0adfcb66b1bfe4482868c425c3c5d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:01:36 -0700 Subject: [PATCH 16/66] [codex] Structure catalog dependency resolution failures (#3298) Co-authored-by: codex --- scripts/lib/resolve-catalog.test.ts | 20 ++++++++++++++++++++ scripts/lib/resolve-catalog.ts | 27 +++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 scripts/lib/resolve-catalog.test.ts diff --git a/scripts/lib/resolve-catalog.test.ts b/scripts/lib/resolve-catalog.test.ts new file mode 100644 index 000000000000..ae9a22911571 --- /dev/null +++ b/scripts/lib/resolve-catalog.test.ts @@ -0,0 +1,20 @@ +import { assert, it } from "@effect/vitest"; + +import { CatalogDependencyResolutionError, resolveCatalogDependencies } from "./resolve-catalog.ts"; + +it("reports unresolved catalog dependencies with lookup context", () => { + try { + resolveCatalogDependencies({ effect: "catalog:runtime" }, {}, "apps/server"); + assert.fail("Expected catalog resolution to fail."); + } catch (error) { + assert.instanceOf(error, CatalogDependencyResolutionError); + assert.equal(error.workspacePackage, "apps/server"); + assert.equal(error.dependencyName, "effect"); + assert.equal(error.catalogSpec, "catalog:runtime"); + assert.equal(error.catalogKey, "runtime"); + assert.equal( + error.message, + "Unable to resolve 'catalog:runtime' for apps/server dependency 'effect'. Expected key 'runtime' in root workspace catalog.", + ); + } +}); diff --git a/scripts/lib/resolve-catalog.ts b/scripts/lib/resolve-catalog.ts index 597bd06c24f3..eb9d4cc78c8a 100644 --- a/scripts/lib/resolve-catalog.ts +++ b/scripts/lib/resolve-catalog.ts @@ -1,3 +1,19 @@ +import * as Schema from "effect/Schema"; + +export class CatalogDependencyResolutionError extends Schema.TaggedErrorClass()( + "CatalogDependencyResolutionError", + { + workspacePackage: Schema.String, + dependencyName: Schema.String, + catalogSpec: Schema.String, + catalogKey: Schema.String, + }, +) { + override get message(): string { + return `Unable to resolve '${this.catalogSpec}' for ${this.workspacePackage} dependency '${this.dependencyName}'. Expected key '${this.catalogKey}' in root workspace catalog.`; + } +} + /** * Resolve `catalog:` dependency specs using the workspace catalog. * @@ -7,7 +23,7 @@ export function resolveCatalogDependencies( dependencies: Record, catalog: Record, - label: string, + workspacePackage: string, ): Record { return Object.fromEntries( Object.entries(dependencies).map(([name, spec]) => { @@ -20,9 +36,12 @@ export function resolveCatalogDependencies( const resolved = catalog[lookupKey]; if (typeof resolved !== "string" || resolved.length === 0) { - throw new Error( - `Unable to resolve '${spec}' for ${label} dependency '${name}'. Expected key '${lookupKey}' in root workspace catalog.`, - ); + throw new CatalogDependencyResolutionError({ + workspacePackage, + dependencyName: name, + catalogSpec: spec, + catalogKey: lookupKey, + }); } return [name, resolved]; From 08650a7424f0f4d28c36d50938c0027e5b4cafff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:02:07 -0700 Subject: [PATCH 17/66] [codex] Structure web diff worker failures (#3356) Co-authored-by: codex --- .../src/components/DiffWorkerPoolProvider.tsx | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 8f7addc5bc7d..3ec748c6bcb2 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,9 +1,20 @@ import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; +import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { + operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), + themeName: Schema.Literals(["pierre-light", "pierre-dark"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Diff worker operation ${this.operation} failed for theme ${this.themeName}.`; + } +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -12,17 +23,23 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { return; } - const current = workerPool.getDiffRenderOptions(); - if (current.theme === themeName) { - return; - } + let operation: DiffWorkerError["operation"] = "get-render-options"; + void (async () => { + try { + const current = workerPool.getDiffRenderOptions(); + if (current.theme === themeName) { + return; + } - void workerPool - .setRenderOptions({ - ...current, - theme: themeName, - }) - .catch(() => undefined); + operation = "set-render-options"; + await workerPool.setRenderOptions({ + ...current, + theme: themeName, + }); + } catch (cause) { + console.error(new DiffWorkerError({ operation, themeName, cause })); + } + })(); }, [themeName, workerPool]); return null; @@ -40,7 +57,17 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { return ( new DiffsWorker(), + workerFactory: () => { + try { + return new DiffsWorker(); + } catch (cause) { + throw new DiffWorkerError({ + operation: "create-worker", + themeName: diffThemeName, + cause, + }); + } + }, poolSize: workerPoolSize, totalASTLRUCacheSize: 240, }} From 8331511b972242301bdaec328148a88b9b6cfa6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:02:37 -0700 Subject: [PATCH 18/66] [codex] structure Electron theme source errors (#3294) Co-authored-by: codex --- .../src/electron/ElectronTheme.test.ts | 22 +++++++++++++++ apps/desktop/src/electron/ElectronTheme.ts | 27 +++++++++++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 0ba7482aacef..4b81943eff2b 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -8,6 +8,7 @@ const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ themeState: { shouldUseDarkColors: true, themeSource: "system", + setSourceError: null as unknown, }, })); @@ -17,6 +18,9 @@ vi.mock("electron", () => ({ return themeState.shouldUseDarkColors; }, set themeSource(value: string) { + if (themeState.setSourceError !== null) { + throw themeState.setSourceError; + } themeState.themeSource = value; }, on: onMock, @@ -32,6 +36,7 @@ describe("ElectronTheme", () => { removeListenerMock.mockClear(); themeState.shouldUseDarkColors = true; themeState.themeSource = "system"; + themeState.setSourceError = null; }); it.effect("scopes native theme update listeners", () => @@ -49,4 +54,21 @@ describe("ElectronTheme", () => { assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); }).pipe(Effect.provide(ElectronTheme.layer)), ); + + it.effect("preserves the requested source and cause when setting the theme fails", () => + Effect.gen(function* () { + const cause = new Error("theme source failed"); + themeState.setSourceError = cause; + const electronTheme = yield* ElectronTheme.ElectronTheme; + + const error = yield* Effect.flip(electronTheme.setSource("dark")); + + assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); + assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); + assert.strictEqual(error.source, "dark"); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "dark"); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef99a31067a6..ef47e3d0954f 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -1,16 +1,31 @@ -import type { DesktopTheme } from "@t3tools/contracts"; +import { DesktopThemeSchema, type DesktopTheme } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; +export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass()( + "ElectronThemeSetSourceError", + { + source: DesktopThemeSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to set the Electron theme source to ${this.source}.`; + } +} + +export const isElectronThemeSetSourceError = Schema.is(ElectronThemeSetSourceError); + export class ElectronTheme extends Context.Service< ElectronTheme, { readonly shouldUseDarkColors: Effect.Effect; - readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; readonly onUpdated: (listener: () => void) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronTheme") {} @@ -18,9 +33,11 @@ export class ElectronTheme extends Context.Service< export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => - Effect.suspend(() => { - Electron.nativeTheme.themeSource = theme; - return Effect.void; + Effect.try({ + try: () => { + Electron.nativeTheme.themeSource = theme; + }, + catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), }), onUpdated: (listener) => Effect.acquireRelease( From f3b43a148b9e39192be5d43415289ac06eca1443 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:03:09 -0700 Subject: [PATCH 19/66] [codex] Structure missing client cloud config errors (#3346) Co-authored-by: codex --- .../mobile/src/features/cloud/publicConfig.test.ts | 13 ++++++++++++- apps/mobile/src/features/cloud/publicConfig.ts | 14 +++++++++++++- apps/web/src/cloud/publicConfig.test.ts | 14 +++++++++++++- apps/web/src/cloud/publicConfig.ts | 14 +++++++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 0307fcdab304..05bf1a8fbccd 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; +import { + CloudPublicConfigMissingError, + hasTracingPublicConfig, + resolveCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -11,6 +16,12 @@ vi.mock("expo-constants", () => ({ })); describe("resolveCloudPublicConfig", () => { + it("reports the missing Clerk JWT template as structured configuration", () => { + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); + it("returns no cloud configuration for an unconfigured build", () => { expect(resolveCloudPublicConfig({})).toEqual({ clerk: { diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 2d304da7c027..93a78fa4f44e 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,6 +1,18 @@ import Constants from "expo-constants"; import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerk: { @@ -87,7 +99,7 @@ export function hasTracingPublicConfig( export function resolveRelayClerkTokenOptions() { const { jwtTemplate } = resolveCloudPublicConfig().clerk; if (!jwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(jwtTemplate); } diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index bb188d0b110e..d42aa34baa26 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { hasCloudPublicConfig } from "./publicConfig.ts"; +import { + CloudPublicConfigMissingError, + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "./publicConfig.ts"; afterEach(() => { vi.unstubAllEnvs(); @@ -30,4 +34,12 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(false); }); + + it("reports the missing Clerk JWT template as structured configuration", () => { + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); + + expect(() => resolveRelayClerkTokenOptions()).toThrowError( + new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }), + ); + }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index f7b3ca6bc31e..d9d0e5f44cb6 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,5 +1,17 @@ import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Schema from "effect/Schema"; + +export class CloudPublicConfigMissingError extends Schema.TaggedErrorClass()( + "CloudPublicConfigMissingError", + { + key: Schema.Literal("T3CODE_CLERK_JWT_TEMPLATE"), + }, +) { + override get message(): string { + return `${this.key} is not configured.`; + } +} export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; @@ -65,7 +77,7 @@ export function hasCloudPublicConfig(): boolean { export function resolveRelayClerkTokenOptions() { const { clerkJwtTemplate } = resolveCloudPublicConfig(); if (!clerkJwtTemplate) { - throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + throw new CloudPublicConfigMissingError({ key: "T3CODE_CLERK_JWT_TEMPLATE" }); } return relayClerkTokenOptions(clerkJwtTemplate); } From b9e22de6a354a97c2ecc772ad00754bfbd7333e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:04:53 -0700 Subject: [PATCH 20/66] [codex] Preserve desktop update state read failures (#3370) Co-authored-by: codex --- apps/web/src/state/desktopUpdate.test.ts | 22 +++++++++++++-- apps/web/src/state/desktopUpdate.ts | 35 ++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index 77409ef611db..f6b3081a80fb 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -1,7 +1,7 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createDesktopUpdateStateAtom } from "./desktopUpdate"; @@ -22,6 +22,10 @@ const baseState: DesktopUpdateState = { canRetry: false, }; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("desktopUpdateStateAtom", () => { it("loads once, retains state, and follows desktop update events", async () => { let listener: ((state: DesktopUpdateState) => void) | undefined; @@ -91,8 +95,11 @@ describe("desktopUpdateStateAtom", () => { it("keeps listening when the initial desktop state read fails", async () => { let listener: ((state: DesktopUpdateState) => void) | undefined; + const cause = new Error("IPC unavailable"); + const reportError = vi.spyOn(console, "log").mockImplementation(() => undefined); + const getUpdateState = vi.fn(async () => Promise.reject(cause)); const atom = createDesktopUpdateStateAtom(() => ({ - getUpdateState: async () => Promise.reject(new Error("IPC unavailable")), + getUpdateState, onUpdateState: (nextListener) => { listener = nextListener; return () => undefined; @@ -102,6 +109,17 @@ describe("desktopUpdateStateAtom", () => { registry.mount(atom); await vi.waitFor(() => expect(listener).toBeDefined()); + await vi.waitFor(() => expect(reportError).toHaveBeenCalledOnce()); + expect(getUpdateState).toHaveBeenCalledTimes(3); + const [, errorMessage, errorContext] = reportError.mock.calls[0] ?? []; + expect(errorMessage).toBe("Failed to read the initial desktop update state after 3 attempts."); + expect(errorContext).toMatchObject({ + errorTag: "DesktopUpdateStateReadError", + attemptCount: 3, + }); + expect(errorContext).not.toHaveProperty("error"); + expect(errorContext).not.toHaveProperty("cause"); + listener?.(baseState); await vi.waitFor(() => { expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState); diff --git a/apps/web/src/state/desktopUpdate.ts b/apps/web/src/state/desktopUpdate.ts index d08169770c31..75764410625c 100644 --- a/apps/web/src/state/desktopUpdate.ts +++ b/apps/web/src/state/desktopUpdate.ts @@ -2,12 +2,27 @@ import { useAtomValue } from "@effect/atom-react"; import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { Atom } from "effect/unstable/reactivity"; type DesktopUpdateBridge = Pick; +const INITIAL_STATE_READ_ATTEMPT_COUNT = 3; + +export class DesktopUpdateStateReadError extends Schema.TaggedErrorClass()( + "DesktopUpdateStateReadError", + { + attemptCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read the initial desktop update state after ${this.attemptCount} attempts.`; + } +} + function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; } @@ -32,9 +47,23 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg (unsubscribe) => Effect.sync(unsubscribe), ); - const initialState = yield* Effect.tryPromise(() => bridge.getUpdateState()).pipe( - Effect.retry({ times: 2 }), - Effect.orElseSucceed(() => null), + const initialState = yield* Effect.tryPromise({ + try: () => bridge.getUpdateState(), + catch: (cause) => + new DesktopUpdateStateReadError({ + attemptCount: INITIAL_STATE_READ_ATTEMPT_COUNT, + cause, + }), + }).pipe( + Effect.retry({ times: INITIAL_STATE_READ_ATTEMPT_COUNT - 1 }), + Effect.catchTags({ + DesktopUpdateStateReadError: (error) => + Effect.logError(error.message, { + errorTag: error._tag, + attemptCount: error.attemptCount, + stack: error.stack, + }).pipe(Effect.as(null)), + }), ); if (!receivedUpdate && initialState !== null) { Queue.offerUnsafe(queue, initialState); From 8112aff7c2dc8a82447920e6fde232565149d32c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:05:24 -0700 Subject: [PATCH 21/66] [codex] Structure relay install confirmation conflicts (#3365) Co-authored-by: codex --- .../cloud/relayClientInstallDialog.test.ts | 25 ++++++++++++++ .../web/src/cloud/relayClientInstallDialog.ts | 34 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index 8f2a25bc3a04..7bd8d4967e41 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -4,6 +4,7 @@ import { completeRelayClientInstallDialogClose, finishRelayClientInstall, readRelayClientInstallDialogState, + RelayClientInstallConfirmationConflictError, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, resetRelayClientInstallDialogForTests, @@ -67,4 +68,28 @@ describe("relay client install dialog coordinator", () => { completeRelayClientInstallDialogClose(); expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); }); + + it("rejects concurrent confirmation with the active install state", async () => { + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + respondToRelayClientInstallConfirmation(true); + await expect(confirmation).resolves.toBe(true); + reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + + const error = await requestRelayClientInstallConfirmation("2026.6.0").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(RelayClientInstallConfirmationConflictError); + expect(error).toMatchObject({ + requestedVersion: "2026.6.0", + activeVersion: "2026.5.2", + activeDialogStatus: "installing", + activeInstallStage: "downloading", + }); + expect(error).not.toHaveProperty("cause"); + expect((error as Error).message).toBe( + "Cannot confirm relay client installation 2026.6.0; installation 2026.5.2 has dialog status installing.", + ); + }); }); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts index 908890ad1f53..b1b0c6607e35 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -1,7 +1,23 @@ -import type { - RelayClientInstallProgressEvent, - RelayClientInstallProgressStage, +import { + RelayClientInstallProgressStageSchema, + type RelayClientInstallProgressEvent, + type RelayClientInstallProgressStage, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class RelayClientInstallConfirmationConflictError extends Schema.TaggedErrorClass()( + "RelayClientInstallConfirmationConflictError", + { + requestedVersion: Schema.String, + activeVersion: Schema.String, + activeDialogStatus: Schema.Literals(["confirming", "installing", "closing"]), + activeInstallStage: Schema.optional(RelayClientInstallProgressStageSchema), + }, +) { + override get message(): string { + return `Cannot confirm relay client installation ${this.requestedVersion}; installation ${this.activeVersion} has dialog status ${this.activeDialogStatus}.`; + } +} export type RelayClientInstallDialogState = | { readonly status: "idle" } @@ -47,7 +63,17 @@ export function subscribeRelayClientInstallDialog(listener: () => void): () => v export function requestRelayClientInstallConfirmation(version: string): Promise { if (state.status !== "idle") { - return Promise.reject(new Error("A relay client installation is already in progress.")); + const activeInstall = state.status === "closing" ? state.view : state; + return Promise.reject( + new RelayClientInstallConfirmationConflictError({ + requestedVersion: version, + activeVersion: activeInstall.version, + activeDialogStatus: state.status, + ...(activeInstall.status === "installing" + ? { activeInstallStage: activeInstall.stage } + : {}), + }), + ); } publish({ status: "confirming", version }); From 350e229f7a5d5c4cdc9a29cf2d1f911ef27a9a48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:05:40 -0700 Subject: [PATCH 22/66] [codex] Structure OAuth scope encoding failures (#3368) Co-authored-by: codex --- packages/shared/src/oauthScope.test.ts | 28 ++++++++++++++++++++- packages/shared/src/oauthScope.ts | 35 ++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/oauthScope.test.ts b/packages/shared/src/oauthScope.test.ts index 0aa4ef595a8b..f5cc247a24a5 100644 --- a/packages/shared/src/oauthScope.test.ts +++ b/packages/shared/src/oauthScope.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; -import { encodeOAuthScope, parseAllowedOAuthScope, parseOAuthScope } from "./oauthScope.ts"; +import { + encodeOAuthScope, + OAuthScopeEncodingError, + parseAllowedOAuthScope, + parseOAuthScope, +} from "./oauthScope.ts"; + +const isOAuthScopeEncodingError = Schema.is(OAuthScopeEncodingError); describe("OAuth scopes", () => { it("parses an RFC 6749 space-delimited scope set without duplicating permissions", () => { @@ -32,4 +40,22 @@ describe("OAuth scopes", () => { }), ).toBeNull(); }); + + it("reports invalid encoding input structurally", () => { + expect.assertions(5); + + try { + encodeOAuthScope(["access:read", "invalid scope", "access:read"]); + } catch (error) { + expect(error).toBeInstanceOf(OAuthScopeEncodingError); + if (!isOAuthScopeEncodingError(error)) return; + + expect(error.scopes).toEqual(["access:read", "invalid scope", "access:read"]); + expect(error.invalidScopes).toEqual(["invalid scope"]); + expect(error.duplicateScopes).toEqual(["access:read"]); + expect(error.message).toBe( + "OAuth scopes must be non-empty, syntactically valid, and unique.", + ); + } + }); }); diff --git a/packages/shared/src/oauthScope.ts b/packages/shared/src/oauthScope.ts index 47c6dd7051ba..4f4274406605 100644 --- a/packages/shared/src/oauthScope.ts +++ b/packages/shared/src/oauthScope.ts @@ -1,5 +1,20 @@ +import * as Schema from "effect/Schema"; + const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; +export class OAuthScopeEncodingError extends Schema.TaggedErrorClass()( + "OAuthScopeEncodingError", + { + scopes: Schema.Array(Schema.String), + invalidScopes: Schema.Array(Schema.String), + duplicateScopes: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return "OAuth scopes must be non-empty, syntactically valid, and unique."; + } +} + /** * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen * order for canonical responses and logs. @@ -18,12 +33,22 @@ export function parseOAuthScope(value: string): ReadonlyArray | null { } export function encodeOAuthScope(scopes: ReadonlyArray): string { - const encoded = scopes.join(" "); - const parsed = parseOAuthScope(encoded); - if (parsed === null || parsed.length !== scopes.length) { - throw new Error("OAuth scopes must be non-empty, valid, and unique."); + const invalidScopes = scopes.filter((scope) => !OAUTH_SCOPE_TOKEN.test(scope)); + const seen = new Set(); + const duplicateScopes = new Set(); + for (const scope of scopes) { + if (seen.has(scope)) duplicateScopes.add(scope); + seen.add(scope); + } + + if (scopes.length === 0 || invalidScopes.length > 0 || duplicateScopes.size > 0) { + throw new OAuthScopeEncodingError({ + scopes, + invalidScopes, + duplicateScopes: [...duplicateScopes], + }); } - return encoded; + return scopes.join(" "); } export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean { From 779c2374876884992c7cdf059556acd255db824e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:06:08 -0700 Subject: [PATCH 23/66] [codex] Structure native view resolution failures (#3353) Co-authored-by: codex --- .../diffs/nativeReviewDiffSurface.test.ts | 15 ++++++++++++++- .../features/diffs/nativeReviewDiffSurface.ts | 16 +++++++++++++++- .../terminal/nativeTerminalModule.test.ts | 15 ++++++++++++++- .../features/terminal/nativeTerminalModule.ts | 16 +++++++++++++++- .../src/native/nativeViewResolutionError.ts | 13 +++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/native/nativeViewResolutionError.ts diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 65e7539340d8..975bf7be13d1 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -58,10 +58,23 @@ describe("resolveNativeReviewDiffView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); + + expect(resolveNativeReviewDiffView()).toBeNull(); expect(resolveNativeReviewDiffView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3ReviewDiffSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 4a6816634713..7660a047752b 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; interface ExpoGlobalWithViewConfig { @@ -128,6 +130,7 @@ export interface NativeReviewDiffViewProps extends ViewProps { } let cachedNativeReviewDiffView: ComponentType | undefined; +let nativeReviewDiffViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -140,6 +143,10 @@ export function resolveNativeReviewDiffView(): ComponentType( NATIVE_REVIEW_DIFF_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeReviewDiffViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts index c7418a525339..5cb37cbb0a9d 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts @@ -43,10 +43,23 @@ describe("resolveNativeTerminalSurfaceView", () => { it("returns null when the view manager cannot be required", async () => { setExpoViewConfigAvailable(); + const cause = new Error("boom"); expoMocks.requireNativeView.mockImplementation(() => { - throw new Error("boom"); + throw cause; }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + + expect(resolveNativeTerminalSurfaceView()).toBeNull(); expect(resolveNativeTerminalSurfaceView()).toBeNull(); + expect(expoMocks.requireNativeView).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NativeViewResolutionError", + nativeModuleName: "T3TerminalSurface", + cause, + }), + ); + expect(consoleError).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index c4686a38b4e8..e5b1f6300734 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -2,6 +2,8 @@ import type { ComponentType } from "react"; import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { requireNativeView } from "expo"; +import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; + const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; interface ExpoGlobalWithViewConfig { @@ -33,6 +35,7 @@ export interface NativeTerminalSurfaceProps extends ViewProps { } let cachedNativeTerminalSurfaceView: ComponentType | undefined; +let nativeTerminalSurfaceViewResolutionFailed = false; function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( @@ -45,6 +48,10 @@ export function resolveNativeTerminalSurfaceView(): ComponentType( NATIVE_TERMINAL_MODULE_NAME, ); - } catch { + } catch (cause) { + nativeTerminalSurfaceViewResolutionFailed = true; + console.error( + new NativeViewResolutionError({ + nativeModuleName: NATIVE_TERMINAL_MODULE_NAME, + cause, + }), + ); return null; } diff --git a/apps/mobile/src/native/nativeViewResolutionError.ts b/apps/mobile/src/native/nativeViewResolutionError.ts new file mode 100644 index 000000000000..bfcf8351a66c --- /dev/null +++ b/apps/mobile/src/native/nativeViewResolutionError.ts @@ -0,0 +1,13 @@ +import * as Schema from "effect/Schema"; + +export class NativeViewResolutionError extends Schema.TaggedErrorClass()( + "NativeViewResolutionError", + { + nativeModuleName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to resolve native view ${this.nativeModuleName}.`; + } +} From 4fbc4f9b20cc4d2e991349ce30b0118473de4621 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:06:38 -0700 Subject: [PATCH 24/66] [codex] Structure mobile project thread validation errors (#3387) Co-authored-by: codex --- .../projectThreadCreationValidation.ts | 56 +++++++++++++++++++ .../features/threads/use-project-actions.ts | 20 ++++--- 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/projectThreadCreationValidation.ts diff --git a/apps/mobile/src/features/threads/projectThreadCreationValidation.ts b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts new file mode 100644 index 000000000000..e4ad776e23d4 --- /dev/null +++ b/apps/mobile/src/features/threads/projectThreadCreationValidation.ts @@ -0,0 +1,56 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export class ProjectThreadTaskRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadTaskRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + environmentMode: Schema.Literals(["local", "worktree"]), + }, +) { + override get message(): string { + return "Enter a task before starting the thread."; + } +} + +export class ProjectThreadBaseBranchRequiredError extends Schema.TaggedErrorClass()( + "ProjectThreadBaseBranchRequiredError", + { + environmentId: EnvironmentId, + projectId: ProjectId, + }, +) { + override get message(): string { + return "Select a base branch before creating a worktree."; + } +} + +export const ProjectThreadCreationValidationError = Schema.Union([ + ProjectThreadTaskRequiredError, + ProjectThreadBaseBranchRequiredError, +]); +export type ProjectThreadCreationValidationError = typeof ProjectThreadCreationValidationError.Type; + +export function validateProjectThreadCreation(input: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly environmentMode: "local" | "worktree"; + readonly branch: string | null; + readonly initialMessageText: string; +}): ProjectThreadCreationValidationError | null { + if (input.initialMessageText.trim().length === 0) { + return new ProjectThreadTaskRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + environmentMode: input.environmentMode, + }); + } + if (input.environmentMode === "worktree" && !input.branch) { + return new ProjectThreadBaseBranchRequiredError({ + environmentId: input.environmentId, + projectId: input.projectId, + }); + } + return null; +} diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index a0c19d9fe8bd..9531567f4476 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -21,6 +21,7 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; +import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -52,15 +53,16 @@ export function useCreateProjectThread() { const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); - if (initialMessageText.length === 0) { - const error = new Error("Enter a task before starting the thread."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); - } - if (input.envMode === "worktree" && !input.branch) { - const error = new Error("Select a base branch before creating a worktree."); - setPendingConnectionError(error.message); - return AsyncResult.failure(Cause.fail(error)); + const validationError = validateProjectThreadCreation({ + environmentId: input.project.environmentId, + projectId: input.project.id, + environmentMode: input.envMode, + branch: input.branch, + initialMessageText, + }); + if (validationError !== null) { + setPendingConnectionError(validationError.message); + return AsyncResult.failure(Cause.fail(validationError)); } const isWorktree = input.envMode === "worktree"; From ac77fe452bc5c69acbbd05b55029b26ec40d3b43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:07:06 -0700 Subject: [PATCH 25/66] [codex] Preserve relay trace error causes (#3377) Co-authored-by: codex --- packages/shared/src/relayTracing.test.ts | 49 +++++++++++++++++++++++- packages/shared/src/relayTracing.ts | 29 +++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 10f4e1087a37..3bb7f1ea1ac2 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; +import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; -import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts"; +import { + makeRelayClientTracingLayer, + RelayClientTracer, + withRelayClientTracing, +} from "./relayTracing.ts"; function collectingTracer(spans: Array): Tracer.Tracer { return Tracer.make({ @@ -54,4 +61,44 @@ describe("withRelayClientTracing", () => { expect(userSpans).toEqual(["relay.operation"]); }), ); + + it.effect("preserves nested error causes in exported relay spans", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const httpClientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), + ); + const tracingLayer = makeRelayClientTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "relay-traces", + tracesToken: "public-ingest-token", + }, + { + serviceName: "relay-test", + runtime: "test", + client: "test", + }, + ).pipe(Layer.provide(httpClientLayer)); + const rootCause = new Error("relay socket closed"); + const failure = new Error("relay request failed", { cause: rootCause }); + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("relay.failed-operation"), + withRelayClientTracing, + Effect.exit, + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); + expect(payload).toContain("relay request failed"); + expect(payload).toContain("relay socket closed"); + }), + ), + ); + }); }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index ecf035534efe..1259984ea3c9 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -41,7 +41,16 @@ export const withRelayClientTracing = ( ), ); -function traceSafeError(value: unknown): Error { +function cleanTraceStack(error: Error): string { + const stack = error.stack ?? `${error.name}: ${error.message}`; + const lines = stack.split("\n"); + const effectFrameIndex = lines.findIndex( + (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line), + ); + return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); +} + +function traceSafeError(value: unknown, seen = new WeakSet()): Error { const message = value instanceof Error ? value.message @@ -51,12 +60,19 @@ function traceSafeError(value: unknown): Error { typeof value.message === "string" ? value.message : String(value); - const error = new Error(message); + + let cause: Error | undefined; + if (typeof value === "object" && value !== null && !seen.has(value)) { + seen.add(value); + if ("cause" in value && value.cause !== undefined) { + cause = traceSafeError(value.cause, seen); + } + } + + const error = new Error(message, cause ? { cause } : undefined); if (value instanceof Error) { error.name = value.name; - if (value.stack !== undefined) { - error.stack = value.stack; - } + error.stack = cleanTraceStack(value); } else if ( typeof value === "object" && value !== null && @@ -65,6 +81,9 @@ function traceSafeError(value: unknown): Error { ) { error.name = value.name; } + if (cause) { + error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; + } return error; } From 9d5ca2cb7ceec88e392d5dc3a70a5979953e4e3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:07:35 -0700 Subject: [PATCH 26/66] [codex] Structure Electron protocol teardown failures (#3310) Co-authored-by: codex --- .../src/electron/ElectronProtocol.test.ts | 55 +++++++++++++++++++ apps/desktop/src/electron/ElectronProtocol.ts | 25 +++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 619b7e871ab8..56fe009fee22 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -98,6 +99,60 @@ describe("ElectronProtocol", () => { }).pipe(Effect.provide(ElectronProtocol.layer)), ); + it.effect("preserves protocol registration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol registration failed"); + handleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const error = yield* Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3774/"), + clerkFrontendApiHostname: undefined, + }), + ).pipe(Effect.flip); + + assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); + assert.equal(error.scheme, "t3code-dev"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("preserves protocol unregistration failures", () => + Effect.gen(function* () { + const cause = new Error("protocol unregistration failed"); + unhandleMock.mockImplementationOnce(() => { + throw cause; + }); + + const protocol = yield* ElectronProtocol.ElectronProtocol; + const exit = yield* Effect.exit( + Effect.scoped( + protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:3773/"), + backendOrigin: new URL("http://127.0.0.1:3773/"), + clerkFrontendApiHostname: undefined, + }), + ), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); + assert.equal(error.scheme, "t3code"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); + } + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 4c80c2c4900b..757c26178d0d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -31,7 +31,19 @@ export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( + "ElectronProtocolUnregistrationError", + { + scheme: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister Electron protocol scheme "${this.scheme}".`; } } @@ -133,9 +145,14 @@ export const make = Effect.gen(function* () { catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), () => - Effect.sync(() => { - Electron.protocol.unhandle(input.scheme); - }).pipe(Effect.andThen(Ref.set(registered, false))), + Effect.try({ + try: () => Electron.protocol.unhandle(input.scheme), + catch: (cause) => + new ElectronProtocolUnregistrationError({ + scheme: input.scheme, + cause, + }), + }).pipe(Effect.andThen(Ref.set(registered, false)), Effect.orDie), ); }, ); From 30a084c463acb7b8b75550a117b9bd82e98c5ac4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:08:03 -0700 Subject: [PATCH 27/66] [codex] Preserve mobile composer draft failures (#3348) Co-authored-by: codex --- apps/mobile/src/state/use-composer-drafts.ts | 81 ++++++++++++++++---- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index ab1fea9840d1..d0329ad25981 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; @@ -11,6 +12,20 @@ const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; const COMPOSER_DRAFTS_FILE = "drafts.json"; const PERSIST_DEBOUNCE_MS = 200; +export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass()( + "ComposerDraftPersistenceError", + { + operation: Schema.Literals(["open", "read", "decode", "encode", "write", "hydrate"]), + directory: Schema.String, + fileName: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Composer draft persistence operation ${this.operation} failed for ${this.directory}/${this.fileName}.`; + } +} + export interface ComposerDraft { readonly text: string; readonly attachments: ReadonlyArray; @@ -56,12 +71,16 @@ async function getComposerDraftsFile() { } async function loadPersistedComposerDrafts(): Promise> { + let operation: ComposerDraftPersistenceError["operation"] = "open"; try { const file = await getComposerDraftsFile(); if (!file.exists) { return {}; } - const parsed = JSON.parse(await file.text()) as Partial; + operation = "read"; + const raw = await file.text(); + operation = "decode"; + const parsed = JSON.parse(raw) as Partial; if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { return {}; } @@ -75,30 +94,53 @@ async function loadPersistedComposerDrafts(): Promise): Promise { - const file = await getComposerDraftsFile(); - const nonEmptyDrafts = Object.fromEntries( - Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); - const document: PersistedComposerDrafts = { - schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, - drafts: nonEmptyDrafts, - }; - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); + let operation: ComposerDraftPersistenceError["operation"] = "open"; + try { + const file = await getComposerDraftsFile(); + operation = "encode"; + const nonEmptyDrafts = Object.fromEntries( + Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); + const document: PersistedComposerDrafts = { + schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, + drafts: nonEmptyDrafts, + }; + const encoded = JSON.stringify(document); + operation = "write"; + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(encoded); + } catch (cause) { + throw new ComposerDraftPersistenceError({ + operation, + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, + }); } - file.write(JSON.stringify(document)); } async function savePersistedComposerDrafts(drafts: Record): Promise { try { await writePersistedComposerDrafts(drafts); - } catch { + } catch (error) { + console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. } } @@ -128,7 +170,16 @@ export function ensureComposerDraftsLoaded(): void { ...current, }); }) - .catch(() => { + .catch((cause) => { + console.warn( + "[composer-drafts] failed to hydrate drafts", + new ComposerDraftPersistenceError({ + operation: "hydrate", + directory: COMPOSER_DRAFTS_DIRECTORY, + fileName: COMPOSER_DRAFTS_FILE, + cause, + }), + ); // Draft loading is best-effort; in-memory drafts still keep working. }); } From fccecd8749056d4f811962a1523229500e7a4e72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:08:32 -0700 Subject: [PATCH 28/66] [codex] Preserve detached desktop action causes (#3371) Co-authored-by: codex --- .../app/DesktopDetachedActionErrors.test.ts | 37 +++++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.ts | 23 +++++++++--- .../src/window/DesktopApplicationMenu.ts | 24 ++++++++---- 3 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/app/DesktopDetachedActionErrors.test.ts diff --git a/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts new file mode 100644 index 000000000000..ae78080539b7 --- /dev/null +++ b/apps/desktop/src/app/DesktopDetachedActionErrors.test.ts @@ -0,0 +1,37 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; + +import { DesktopLifecycleRelaunchError } from "./DesktopLifecycle.ts"; +import { DesktopApplicationMenuActionError } from "../window/DesktopApplicationMenu.ts"; + +describe("desktop detached action errors", () => { + it("preserves the complete relaunch failure cause and reason", () => { + const cause = Cause.combine( + Cause.fail(new Error("shutdown failed")), + Cause.die(new Error("relaunch defect")), + ); + const error = new DesktopLifecycleRelaunchError({ + reason: "apply update", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.reason, "apply update"); + assert.equal(error.message, 'Desktop relaunch failed for reason "apply update".'); + }); + + it("preserves the complete menu action failure cause and action", () => { + const cause = Cause.combine( + Cause.fail(new Error("window unavailable")), + Cause.die(new Error("dispatch defect")), + ); + const error = new DesktopApplicationMenuActionError({ + action: "open-settings", + cause, + }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.action, "open-settings"); + assert.equal(error.message, 'Desktop menu action "open-settings" failed.'); + }); +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ad08d2f5a2ec..c5264332b661 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -1,8 +1,8 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import type * as Electron from "electron"; @@ -15,6 +15,18 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( + "DesktopLifecycleRelaunchError", + { + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop relaunch failed for reason "${this.reason}".`; + } +} + export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopShutdown.DesktopShutdown @@ -142,11 +154,10 @@ export const make = DesktopLifecycle.of({ }); yield* electronApp.exit(0); }).pipe( - Effect.catchCause((cause) => - logLifecycleError("desktop relaunch failed", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + return logLifecycleError(error.message, { error }); + }), Effect.forkDetach, Effect.asVoid, ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index cfe4f5702a1d..a52707627b0a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -1,8 +1,8 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type * as Electron from "electron"; @@ -14,6 +14,18 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +export class DesktopApplicationMenuActionError extends Schema.TaggedErrorClass()( + "DesktopApplicationMenuActionError", + { + action: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop menu action "${this.action}" failed.`; + } +} + export class DesktopApplicationMenu extends Context.Service< DesktopApplicationMenu, { @@ -100,12 +112,10 @@ export const make = Effect.gen(function* () { effect.pipe( Effect.annotateLogs({ action }), Effect.withSpan("desktop.menu.action"), - Effect.catchCause((cause) => - logMenuError("desktop menu action failed", { - action, - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + const error = new DesktopApplicationMenuActionError({ action, cause }); + return logMenuError(error.message, { error }); + }), ), ); }; From ce0c20b840f9e9d49532c3480113e1e54201fab0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:09:02 -0700 Subject: [PATCH 29/66] [codex] Structure desktop network interface failures (#3313) Co-authored-by: codex --- .../backend/DesktopNetworkInterfaces.test.ts | 65 +++++++++++++++++++ .../src/backend/DesktopNetworkInterfaces.ts | 27 ++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts new file mode 100644 index 000000000000..411af7553f91 --- /dev/null +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const { networkInterfacesMock } = vi.hoisted(() => ({ + networkInterfacesMock: vi.fn(), +})); + +vi.mock("node:os", () => ({ + networkInterfaces: networkInterfacesMock, +})); + +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; + +const TestLayer = DesktopNetworkInterfaces.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + +describe("DesktopNetworkInterfaces", () => { + beforeEach(() => { + networkInterfacesMock.mockReset(); + }); + + it.effect("reads network interfaces through the service", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.10", + family: "IPv4", + internal: false, + }, + ], + }; + networkInterfacesMock.mockReturnValueOnce(interfaces); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + assert.strictEqual(yield* service.read, interfaces); + }).pipe(Effect.provide(TestLayer)); + }); + + it.effect("preserves network interface read failures as structured defects", () => { + const cause = new Error("network interface probe failed"); + networkInterfacesMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const service = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + const exit = yield* Effect.exit(service.read); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopNetworkInterfaces.DesktopNetworkInterfacesReadError); + assert.equal(error.platform, "linux"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read desktop network interfaces on linux."); + } + }).pipe(Effect.provide(TestLayer)); + }); +}); diff --git a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts index 79b6b824c8a0..43f634c44917 100644 --- a/apps/desktop/src/backend/DesktopNetworkInterfaces.ts +++ b/apps/desktop/src/backend/DesktopNetworkInterfaces.ts @@ -1,8 +1,10 @@ import * as NodeOS from "node:os"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; export interface DesktopNetworkInterfaceInfo { readonly address: string; @@ -18,6 +20,18 @@ export type NetworkInterfaces = Readonly< Record >; +export class DesktopNetworkInterfacesReadError extends Schema.TaggedErrorClass()( + "DesktopNetworkInterfacesReadError", + { + platform: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read desktop network interfaces on ${this.platform}.`; + } +} + export class DesktopNetworkInterfaces extends Context.Service< DesktopNetworkInterfaces, { @@ -25,9 +39,14 @@ export class DesktopNetworkInterfaces extends Context.Service< } >()("@t3tools/desktop/backend/DesktopNetworkInterfaces") {} -export const make = (): DesktopNetworkInterfaces["Service"] => - DesktopNetworkInterfaces.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), +export const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return DesktopNetworkInterfaces.of({ + read: Effect.try({ + try: () => NodeOS.networkInterfaces(), + catch: (cause) => new DesktopNetworkInterfacesReadError({ platform, cause }), + }).pipe(Effect.orDie), }); +}); -export const layer = Layer.succeed(DesktopNetworkInterfaces, make()); +export const layer = Layer.effect(DesktopNetworkInterfaces, make); From 04f82ae1f3c50f2c00bbb9c018c768b113b70ae7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:09:30 -0700 Subject: [PATCH 30/66] [codex] Structure relay environment link errors (#3334) Co-authored-by: codex --- .../environments/EnvironmentLinker.test.ts | 25 +++++++ .../src/environments/EnvironmentLinker.ts | 69 +++++++++++++++---- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 35dbd907dbee..f6bd1c6d977d 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayTokens from "../auth/RelayTokens.ts"; @@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, }); +const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid); function signTestJwt(payload: object, typ: string, privateKey: string): string { const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); @@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } expect(persisted).toBe(false); }).pipe( Effect.provide( @@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "replayed_nonce", + stage: "consume_proof_nonce", + }); + } + } }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 9cb422bd317a..6a97eefffa05 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass()( "EnvironmentLinkProofExpired", { + userId: Schema.String, + environmentId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment link proof expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`; } } export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass()( "EnvironmentLinkProofInvalid", { + userId: Schema.String, environmentId: Schema.String, reason: RelayEnvironmentLinkProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "decode_payload", + "verify_proof", + "authorize_capabilities", + "validate_descriptor", + "verify_challenge", + "validate_expiration", + "consume_proof_nonce", + "consume_challenge_nonce", + "validate_origin", + "validate_endpoint", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' link proof is invalid: ${this.reason}`; + return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`; } } @@ -132,20 +149,27 @@ const make = Effect.gen(function* () { const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); const unverified = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => + catch: (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: "unknown", reason: "invalid_signature_or_scope", + stage: "decode_token", + cause, }), }); - const decoded = yield* decodeProof(unverified).pipe(Effect.option); - if (decoded._tag === "None") { - return yield* new EnvironmentLinkProofInvalid({ - environmentId: "unknown", - reason: "invalid_signature_or_scope", - }); - } - const candidate = decoded.value; + const candidate = yield* decodeProof(unverified).pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkProofInvalid({ + userId: input.userId, + environmentId: "unknown", + reason: "invalid_signature_or_scope", + stage: "decode_payload", + cause, + }), + ), + ); yield* Effect.annotateCurrentSpan({ "relay.environment_id": candidate.environmentId, "relay.link.notifications_enabled": input.request.notificationsEnabled, @@ -154,6 +178,8 @@ const make = Effect.gen(function* () { }); if (candidate.exp <= nowSeconds) { return yield* new EnvironmentLinkProofExpired({ + userId: input.userId, + environmentId: candidate.environmentId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), }); } @@ -169,10 +195,13 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => + (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause, }), ), ); @@ -181,14 +210,18 @@ const make = Effect.gen(function* () { !proofAuthorizesRequestedCapabilities(verified, input.request) ) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "authorize_capabilities", }); } if (verified.descriptor.environmentId !== verified.environmentId) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "descriptor_mismatch", + stage: "validate_descriptor", }); } const challenge = yield* relayTokens.verifyLinkChallenge({ @@ -203,15 +236,19 @@ const make = Effect.gen(function* () { }); if (challenge === null) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "verify_challenge", }); } const expiresAt = DateTime.make(verified.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "invalid_signature_or_scope", + stage: "validate_expiration", }); } const consumedNonce = yield* proofReplay.consume({ @@ -222,8 +259,10 @@ const make = Effect.gen(function* () { }); if (!consumedNonce) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "replayed_nonce", + stage: "consume_proof_nonce", }); } const consumedChallenge = yield* proofReplay.consume({ @@ -234,14 +273,18 @@ const make = Effect.gen(function* () { }); if (!consumedChallenge) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "consume_challenge_nonce", }); } if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "origin_not_allowed", + stage: "validate_origin", }); } const provisioned = input.request.managedTunnelsEnabled @@ -254,8 +297,10 @@ const make = Effect.gen(function* () { const endpoint = provisioned?.endpoint ?? verified.endpoint; if (!isSecureManagedEndpoint(endpoint)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "endpoint_not_secure", + stage: "validate_endpoint", }); } yield* links.upsert({ ...input, proof: verified, endpoint }); From 61aade9ea4e7df15fd196d9805a00d24ed715f7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:10:16 -0700 Subject: [PATCH 31/66] [codex] Preserve desktop asset probe failures (#3373) Co-authored-by: codex --- apps/desktop/src/app/DesktopAssets.test.ts | 57 ++++++++++++++++++++++ apps/desktop/src/app/DesktopAssets.ts | 45 ++++++++++++++--- 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/DesktopAssets.test.ts diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts new file mode 100644 index 000000000000..2eb55c72057f --- /dev/null +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -0,0 +1,57 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; + +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); + +describe("DesktopAssets", () => { + it.effect("preserves the failed asset candidate and filesystem cause", () => + Effect.gen(function* () { + const fileName = "custom.bin"; + const candidatePath = "/repo/apps/desktop/resources/custom.bin"; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: candidatePath, + description: "private filesystem diagnostic", + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), + }); + const assetsLayer = DesktopAssets.layer.pipe( + Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)), + ); + const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(assetsLayer)); + + const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); + + assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); + assert.equal(error.fileName, fileName); + assert.equal(error.candidatePath, candidatePath); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, + ); + assert.notInclude(error.message, "private filesystem diagnostic"); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 7591d6fd295a..95585acab74e 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -12,11 +13,26 @@ export interface DesktopIconPaths { readonly png: Option.Option; } +export class DesktopAssetProbeError extends Schema.TaggedErrorClass()( + "DesktopAssetProbeError", + { + fileName: Schema.String, + candidatePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to probe desktop asset "${this.fileName}" at ${this.candidatePath}.`; + } +} + export class DesktopAssets extends Context.Service< DesktopAssets, { readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: (fileName: string) => Effect.Effect>; + readonly resolveResourcePath: ( + fileName: string, + ) => Effect.Effect, DesktopAssetProbeError>; } >()("@t3tools/desktop/app/DesktopAssets") {} @@ -24,14 +40,20 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func fileName: string, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const candidates = environment.resolveResourcePathCandidates(fileName); for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fileSystem + .exists(candidate) + .pipe( + Effect.mapError( + (cause) => new DesktopAssetProbeError({ fileName, candidatePath: candidate, cause }), + ), + ); if (exists) { return Option.some(candidate); } @@ -43,16 +65,23 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, ): Effect.fn.Return< Option.Option, - never, + DesktopAssetProbeError, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; - const developmentDockIconExists = yield* fileSystem - .exists(developmentDockIconPath) - .pipe(Effect.orElseSucceed(() => false)); + const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe( + Effect.mapError( + (cause) => + new DesktopAssetProbeError({ + fileName: "icon.png", + candidatePath: developmentDockIconPath, + cause, + }), + ), + ); if (developmentDockIconExists) { return Option.some(developmentDockIconPath); } @@ -61,7 +90,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( return yield* resolveResourcePath(`icon.${ext}`); }); -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const context = yield* Effect.context< FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment >(); From 5a2c92e86d16ecba823fdc61e163b8c35969f668 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:10:45 -0700 Subject: [PATCH 32/66] [codex] Structure Electron app boundary failures (#3301) Co-authored-by: codex --- apps/desktop/src/electron/ElectronApp.test.ts | 38 ++++++++++ apps/desktop/src/electron/ElectronApp.ts | 70 ++++++++++++++++--- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f6ed5cb1df73..f3ce3b4b5f43 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -100,6 +100,44 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reports which app metadata property failed", () => + Effect.gen(function* () { + const cause = new Error("version unavailable"); + getVersionMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.metadata.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppMetadataReadError); + assert.strictEqual(error.property, "app-version"); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + 'Failed to read Electron app metadata property "app-version".', + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("preserves Electron readiness failures", () => + Effect.gen(function* () { + const cause = new Error("ready failed"); + whenReadyMock.mockRejectedValueOnce(cause); + + const electronApp = yield* ElectronApp.ElectronApp; + const error = yield* electronApp.whenReady.pipe(Effect.flip); + + assert.instanceOf(error, ElectronApp.ElectronAppWhenReadyError); + assert.strictEqual(error.isPackaged, true); + assert.strictEqual(error.cause, cause); + assert.strictEqual( + error.message, + "Failed to wait for the Electron app to become ready (packaged: true).", + ); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("scopes app event listeners", () => Effect.gen(function* () { const listener = vi.fn(); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 3e894001e10d..0af8691f6c45 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Electron from "electron"; @@ -13,12 +14,36 @@ export interface ElectronAppMetadata { readonly runningUnderArm64Translation: boolean; } +export class ElectronAppMetadataReadError extends Schema.TaggedErrorClass()( + "ElectronAppMetadataReadError", + { + property: Schema.Literals(["app-version", "app-path"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read Electron app metadata property "${this.property}".`; + } +} + +export class ElectronAppWhenReadyError extends Schema.TaggedErrorClass()( + "ElectronAppWhenReadyError", + { + isPackaged: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to wait for the Electron app to become ready (packaged: ${this.isPackaged}).`; + } +} + export class ElectronApp extends Context.Service< ElectronApp, { - readonly metadata: Effect.Effect; + readonly metadata: Effect.Effect; readonly name: Effect.Effect; - readonly whenReady: Effect.Effect; + readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; @@ -63,15 +88,40 @@ const addScopedAppListener = >( ).pipe(Effect.asVoid); export const make = ElectronApp.of({ - metadata: Effect.sync(() => ({ - appVersion: Electron.app.getVersion(), - appPath: Electron.app.getAppPath(), - isPackaged: Electron.app.isPackaged, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, - })), + metadata: Effect.gen(function* () { + const appVersion = yield* Effect.try({ + try: () => Electron.app.getVersion(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-version", + cause, + }), + }); + const appPath = yield* Effect.try({ + try: () => Electron.app.getAppPath(), + catch: (cause) => + new ElectronAppMetadataReadError({ + property: "app-path", + cause, + }), + }); + + return { + appVersion, + appPath, + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + }; + }), name: Effect.sync(() => Electron.app.name), - whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + whenReady: Effect.gen(function* () { + const isPackaged = Electron.app.isPackaged; + yield* Effect.tryPromise({ + try: () => Electron.app.whenReady(), + catch: (cause) => new ElectronAppWhenReadyError({ isPackaged, cause }), + }); + }), quit: Effect.sync(() => { Electron.app.quit(); }), From d84ebe831922167d556e16cb40a238a904e0f14a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:11:15 -0700 Subject: [PATCH 33/66] [codex] Structure process resource sampling failures (#3415) Co-authored-by: codex --- .../ProcessResourceMonitor.test.ts | 33 +++++++++- .../src/diagnostics/ProcessResourceMonitor.ts | 66 ++++++++++++++----- packages/contracts/src/server.ts | 11 ++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index 49a9676ab11d..d9c4eb06ef18 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -111,7 +111,7 @@ describe("ProcessResourceMonitor", () => { readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(Option.isNone(result.error)).toBe(true); @@ -171,7 +171,7 @@ describe("ProcessResourceMonitor", () => { readAtMs: DateTime.toEpochMillis(secondAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(1); @@ -218,11 +218,38 @@ describe("ProcessResourceMonitor", () => { readAtMs: DateTime.toEpochMillis(sampledAt), windowMs: 60_000, bucketMs: 10_000, - lastError: null, + lastFailure: null, }); expect(result.topProcesses).toHaveLength(36); expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true); }), ); + + it.effect("exposes bounded failure diagnostics while retaining the exact cause", () => + Effect.sync(() => { + const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + const cause = new Error("stderr included credential=secret-value"); + const failure = new ProcessResourceMonitor.ProcessResourceSamplingError({ + failureTag: "ProcessDiagnosticsQueryFailedError", + cause, + }); + + const result = ProcessResourceMonitor.aggregateProcessResourceHistory({ + samples: [], + readAt, + readAtMs: DateTime.toEpochMillis(readAt), + windowMs: 60_000, + bucketMs: 10_000, + lastFailure: failure, + }); + + expect(failure.cause).toBe(cause); + expect(Option.getOrThrow(result.error)).toEqual({ + failureTag: "ProcessDiagnosticsQueryFailedError", + message: "Failed to sample process resources (ProcessDiagnosticsQueryFailedError).", + }); + expect(Option.getOrThrow(result.error).message).not.toContain("secret-value"); + }), + ); }); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index b6e71dd24237..6030e4172e1d 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -1,8 +1,10 @@ -import type { - ServerProcessResourceHistoryBucket, - ServerProcessResourceHistoryInput, - ServerProcessResourceHistoryResult, - ServerProcessResourceHistorySummary, +import { + ServerProcessResourceHistoryFailureTag, + type ServerProcessResourceHistoryBucket, + type ServerProcessResourceHistoryFailureTag as ServerProcessResourceHistoryFailureTagType, + type ServerProcessResourceHistoryInput, + type ServerProcessResourceHistoryResult, + type ServerProcessResourceHistorySummary, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -10,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -31,9 +34,21 @@ export interface ProcessResourceSample { readonly isServerRoot: boolean; } +export class ProcessResourceSamplingError extends Schema.TaggedErrorClass()( + "ProcessResourceSamplingError", + { + failureTag: ServerProcessResourceHistoryFailureTag, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sample process resources (${this.failureTag}).`; + } +} + interface MonitorState { readonly samples: ReadonlyArray; - readonly lastError: string | null; + readonly lastFailure: ProcessResourceSamplingError | null; } export class ProcessResourceMonitor extends Context.Service< @@ -218,7 +233,7 @@ export function aggregateProcessResourceHistory(input: { readonly readAtMs: number; readonly windowMs: number; readonly bucketMs: number; - readonly lastError: string | null; + readonly lastFailure: ProcessResourceSamplingError | null; }): ServerProcessResourceHistoryResult { const windowMs = Math.max(1_000, input.windowMs); const bucketMs = Math.max(1_000, input.bucketMs); @@ -239,13 +254,29 @@ export function aggregateProcessResourceHistory(input: { totalCpuSecondsApprox, buckets: buildBuckets({ samples, nowMs: input.readAtMs, windowMs, bucketMs }), topProcesses, - error: input.lastError ? Option.some({ message: input.lastError }) : Option.none(), + error: input.lastFailure + ? Option.some({ + failureTag: input.lastFailure.failureTag, + message: input.lastFailure.message, + }) + : Option.none(), }; } export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const state = yield* Ref.make({ samples: [], lastError: null }); + const state = yield* Ref.make({ samples: [], lastFailure: null }); + + const recordSamplingFailure = (cause: { + readonly _tag: ServerProcessResourceHistoryFailureTagType; + }) => + Ref.update(state, (current) => ({ + ...current, + lastFailure: new ProcessResourceSamplingError({ + failureTag: cause._tag, + cause, + }), + })); const sampleOnce = Effect.gen(function* () { const sampledAt = yield* DateTime.now; @@ -261,15 +292,16 @@ export const make = Effect.gen(function* () { }); yield* Ref.update(state, (current) => ({ samples: trimSamples([...current.samples, ...samples], sampledAtMs), - lastError: null, + lastFailure: null, })); }).pipe( - Effect.catch((error: unknown) => - Ref.update(state, (current) => ({ - ...current, - lastError: error instanceof Error ? error.message : "Failed to sample process resources.", - })), - ), + Effect.catchTags({ + ProcessDiagnosticsQueryTimeoutError: recordSamplingFailure, + ProcessDiagnosticsQueryFailedError: recordSamplingFailure, + ProcessDiagnosticsServerProcessSignalError: recordSamplingFailure, + ProcessDiagnosticsNotDescendantError: recordSamplingFailure, + ProcessDiagnosticsSignalFailedError: recordSamplingFailure, + }), ); yield* Effect.forever(sampleOnce.pipe(Effect.andThen(Effect.sleep(SAMPLE_INTERVAL_MS)))).pipe( @@ -287,7 +319,7 @@ export const make = Effect.gen(function* () { readAtMs, windowMs: input.windowMs, bucketMs: input.bucketMs, - lastError: current.lastError, + lastFailure: current.lastFailure, }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1aa280ad63b2..b76ea965afe7 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -364,6 +364,16 @@ export const ServerProcessResourceHistorySummary = Schema.Struct({ }); export type ServerProcessResourceHistorySummary = typeof ServerProcessResourceHistorySummary.Type; +export const ServerProcessResourceHistoryFailureTag = Schema.Literals([ + "ProcessDiagnosticsQueryTimeoutError", + "ProcessDiagnosticsQueryFailedError", + "ProcessDiagnosticsServerProcessSignalError", + "ProcessDiagnosticsNotDescendantError", + "ProcessDiagnosticsSignalFailedError", +]); +export type ServerProcessResourceHistoryFailureTag = + typeof ServerProcessResourceHistoryFailureTag.Type; + export const ServerProcessResourceHistoryResult = Schema.Struct({ readAt: Schema.DateTimeUtc, windowMs: NonNegativeInt, @@ -375,6 +385,7 @@ export const ServerProcessResourceHistoryResult = Schema.Struct({ topProcesses: Schema.Array(ServerProcessResourceHistorySummary), error: Schema.Option( Schema.Struct({ + failureTag: ServerProcessResourceHistoryFailureTag, message: TrimmedNonEmptyString, }), ), From 53a477c2e91d206057adc5ac4c28cbdaddfbc07c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:11:43 -0700 Subject: [PATCH 34/66] [codex] Structure desktop backend settings read errors (#3379) Co-authored-by: codex --- .../DesktopBackendConfiguration.test.ts | 62 +++++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 51 ++++++++++----- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index cb68b2cd47fc..43e77a0c4cb1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -3,6 +3,8 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -21,6 +23,10 @@ const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), ); +const isDesktopBackendObservabilitySettingsReadError = Schema.is( + DesktopBackendConfiguration.DesktopBackendObservabilitySettingsReadError, +); + const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ @@ -166,6 +172,62 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("logs structured context when persisted observability settings cannot be read", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const settingsPath = `${baseDir}/userdata/settings.json`; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: settingsPath, + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const failingFileSystemLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.fail(cause), + }), + ); + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolve; + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + Layer.provideMerge(failingFileSystemLayer), + ), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + + const error = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .find(isDesktopBackendObservabilitySettingsReadError); + assert.isDefined(error); + assert.equal(error.settingsPath, settingsPath); + assert.equal(error.cause, cause); + assert.equal( + error.message, + `Failed to read persisted backend observability settings at ${settingsPath}.`, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("captures backend output in development so child process logs can be persisted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ec72faf910b6..d8bd1a13dcb0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -8,12 +8,24 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( + "DesktopBackendObservabilitySettingsReadError", + { + settingsPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read persisted backend observability settings at ${this.settingsPath}.`; + } +} + export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, { @@ -50,25 +62,34 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); -const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( - "desktop-backend-configuration", -); +const logBackendObservabilitySettingsReadFailure = ( + settingsPath: string, + cause: PlatformError.PlatformError, +) => { + const error = new DesktopBackendObservabilitySettingsReadError({ settingsPath, cause }); + return Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-backend-configuration", + error, + }), + ); +}; const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const exists = yield* fileSystem - .exists(environment.serverSettingsPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return emptyBackendObservabilitySettings; - } - - const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( + Effect.as(Option.none()), + ), + }), + ); if (Option.isNone(raw)) { - yield* logBackendConfigurationWarning( - "failed to read persisted backend observability settings", - ); return emptyBackendObservabilitySettings; } From 300d4d566a1044dc401bcd7ebf43def08621229e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:12:02 -0700 Subject: [PATCH 35/66] [codex] Structure missing provider command failures (#3384) Co-authored-by: codex --- .../src/provider/providerSnapshot.test.ts | 78 ++++++++++++++++++- apps/server/src/provider/providerSnapshot.ts | 36 ++++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index fdc8b4c4a71c..abe138fdfb94 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -1,8 +1,19 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, +} from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +53,66 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("ProviderCommandNotFoundError", () => { + it("classifies normalized platform failures without parsing messages", () => { + expect( + isCommandMissingCause( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "arbitrary host detail", + }), + ), + ).toBe(true); + expect(isCommandMissingCause(new Error("spawn provider ENOENT"))).toBe(false); + }); + + it.effect("retains safe failed-command diagnostics without process output", () => { + const stderr = "'codex' is not recognized: secret-token-value"; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(9009)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + return Effect.gen(function* () { + const error = yield* spawnAndCollect( + "C:\\tools\\codex.cmd", + ChildProcess.make("codex", ["--version"]), + ).pipe( + Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.flip, + ); + + if (error._tag !== "ProviderCommandNotFoundError") { + throw new Error(`Unexpected error: ${error._tag}`); + } + + expect(error.binaryPath).toBe("C:\\tools\\codex.cmd"); + expect(error.exitCode).toBe(9009); + expect(error.stdoutLength).toBe(0); + expect(error.stderrLength).toBe(stderr.length); + expect(error.message).toBe( + "Provider command C:\\tools\\codex.cmd was not found (exit code 9009).", + ); + expect(isCommandMissingCause(error)).toBe(true); + expect(error).not.toHaveProperty("stdout"); + expect(error).not.toHaveProperty("stderr"); + expect(error.message).not.toContain("secret-token-value"); + }); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 2ecb32207730..dfe31ffdc442 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,7 +9,8 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Data from "effect/Data"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -27,11 +28,21 @@ export interface CommandResult { readonly code: number; } -export class ProviderCommandExecutionError extends Data.TaggedError( - "ProviderCommandExecutionError", -)<{ - readonly message: string; -}> {} +export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass()( + "ProviderCommandNotFoundError", + { + binaryPath: Schema.String, + exitCode: Schema.Number, + stdoutLength: Schema.Number, + stderrLength: Schema.Number, + }, +) { + override get message(): string { + return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`; + } +} + +const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); export interface ProviderProbeResult { readonly installed: boolean; @@ -56,9 +67,9 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -export function isCommandMissingCause(error: { readonly message: string }): boolean { - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); +export function isCommandMissingCause(error: unknown): boolean { + if (isProviderCommandNotFoundError(error)) return true; + return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; } export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => @@ -76,7 +87,12 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman const result: CommandResult = { stdout, stderr, code: exitCode }; if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new ProviderCommandExecutionError({ message: `spawn ${binaryPath} ENOENT` }); + return yield* new ProviderCommandNotFoundError({ + binaryPath, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + }); } return result; }).pipe(Effect.scoped); From 716ae73c40e988b462ab73f2bd7aaca789054e35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:12:31 -0700 Subject: [PATCH 36/66] [codex] Structure relay publish signature errors (#3335) Co-authored-by: codex --- .../EnvironmentPublishSignatures.test.ts | 58 +++++++++++++++++++ .../EnvironmentPublishSignatures.ts | 56 ++++++++++++++++-- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 2b19d4c9f1f6..f61c5a27d5bc 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; @@ -51,6 +52,9 @@ const state: RelayAgentActivityState = { updatedAt: "2026-05-25T00:00:00.000Z", deepLink: "/threads/env/thread", }; +const isEnvironmentPublishSignatureInvalid = Schema.is( + EnvironmentPublishSignatures.EnvironmentPublishSignatureInvalid, +); function signTestJwt(payload: object, privateKey: string): string { const header = Buffer.from( @@ -145,6 +149,49 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", + }); + } + } + }).pipe(Effect.provide(layer())), + ); + + it.effect("preserves the JWT verification failure", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const segments = request.proof.split("."); + const signature = segments[2]!; + segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, proof: segments.join(".") }, + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } }).pipe(Effect.provide(layer())), ); @@ -161,6 +208,17 @@ describe("EnvironmentPublishSignatures", () => { }), ); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentPublishSignatureInvalid(result.failure)).toBe(true); + if (isEnvironmentPublishSignatureInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + environmentId: state.environmentId, + threadId: state.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", + }); + } + } }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index ffc8c124b7b0..eb9c15a75aa3 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -1,5 +1,6 @@ import { RelayAgentActivityPublishProofPayload, + RelayAgentActivityPublishProofInvalidReason, type RelayAgentActivityPublishRequest, } from "@t3tools/contracts/relay"; import { @@ -23,11 +24,13 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentPublishSignatureExpired extends Schema.TaggedErrorClass()( "EnvironmentPublishSignatureExpired", { + environmentId: Schema.String, + threadId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment publish signature expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' expired at ${this.expiresAt}`; } } @@ -35,10 +38,21 @@ export class EnvironmentPublishSignatureInvalid extends Schema.TaggedErrorClass< "EnvironmentPublishSignatureInvalid", { environmentId: Schema.String, + threadId: Schema.String, + reason: RelayAgentActivityPublishProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "verify_proof", + "validate_claims", + "validate_expiration", + "generate_replay_thumbprint", + "consume_nonce", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' publish signature is invalid`; + return `Environment '${this.environmentId}' publish signature for thread '${this.threadId}' is invalid during ${this.stage}: ${this.reason}`; } } @@ -102,13 +116,22 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const decoded = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + catch: (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "decode_token", + cause, + }), }); if ( typeof decoded.exp === "number" && decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) ) { return yield* new EnvironmentPublishSignatureExpired({ + environmentId: input.environmentId, + threadId: input.threadId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), }); } @@ -122,7 +145,14 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "verify_proof", + cause, + }), ), ); if ( @@ -136,12 +166,18 @@ const make = Effect.gen(function* () { ) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_claims", }); } const expiresAt = DateTime.make(proof.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "validate_expiration", }); } const thumbprint = yield* crypto @@ -155,7 +191,14 @@ const make = Effect.gen(function* () { .pipe( Effect.map(formatEnvironmentPublishReplayThumbprint), Effect.mapError( - () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + (cause) => + new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + threadId: input.threadId, + reason: "invalid_signature_or_payload", + stage: "generate_replay_thumbprint", + cause, + }), ), ); const consumedNonce = yield* proofReplay.consume({ @@ -167,6 +210,9 @@ const make = Effect.gen(function* () { if (!consumedNonce) { return yield* new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId, + threadId: input.threadId, + reason: "replayed_nonce", + stage: "consume_nonce", }); } }), From 3ecf3685ba7dbaca04f6a1219a8d42e6094dc526 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:13:02 -0700 Subject: [PATCH 37/66] [codex] structure Bitbucket API failures (#3332) Co-authored-by: codex --- .../src/sourceControl/BitbucketApi.test.ts | 97 ++++++++++++++++++- apps/server/src/sourceControl/BitbucketApi.ts | 19 ++-- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 5041fe6635b8..e4a7649e74a9 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -6,8 +6,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; - +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -53,10 +59,15 @@ const repositoryJson = { function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly requestFailure?: ( + request: HttpClientRequest.HttpClientRequest, + ) => HttpClientError.HttpClientError; readonly git?: Partial; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => - Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), + input.requestFailure + ? Effect.fail(input.requestFailure(request)) + : Effect.succeed(HttpClientResponse.fromWeb(request, input.response(request))), ); const gitMock = { readConfigValue: vi.fn(() => @@ -497,6 +508,42 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { + const transportCause = new Error("socket reset by peer"); + let requestFailure: HttpClientError.HttpClientError | undefined; + const { layer } = makeLayer({ + response: () => Response.json({}), + requestFailure: (request) => { + requestFailure = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: transportCause, + }), + }); + return requestFailure; + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.getPullRequest({ + cwd: "/repo", + reference: "42", + }), + ); + + assert.strictEqual(error.operation, "getPullRequest"); + assert.strictEqual(error.detail, "Failed to send the Bitbucket request."); + assert.strictEqual( + error.message, + "Bitbucket API failed in getPullRequest: Failed to send the Bitbucket request.", + ); + assert.strictEqual(error.cause, requestFailure); + assert.strictEqual(requestFailure?.cause, transportCause); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out same-repository pull requests with the existing Bitbucket remote", () => { const { git, layer } = makeLayer({ response: () => @@ -549,6 +596,50 @@ it.effect("checks out same-repository pull requests with the existing Bitbucket }).pipe(Effect.provide(layer)); }); +it.effect("preserves Git checkout failures without deriving the domain message from them", () => { + const gitCause = new GitCommandError({ + operation: "fetchRemoteBranch", + command: "git fetch origin feature/source-control", + cwd: "/repo", + detail: "remote rejected the request", + }); + const { layer } = makeLayer({ + response: () => + Response.json({ + ...bitbucketPullRequest, + source: { + branch: { name: "feature/source-control" }, + repository: { + full_name: "pingdotgg/t3code", + workspace: { slug: "pingdotgg" }, + }, + }, + }), + git: { + fetchRemoteBranch: () => Effect.fail(gitCause), + }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const error = yield* Effect.flip( + bitbucket.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + force: true, + }), + ); + + assert.strictEqual(error.operation, "checkoutPullRequest"); + assert.strictEqual(error.detail, "Failed to check out the Bitbucket pull request."); + assert.strictEqual( + error.message, + "Bitbucket API failed in checkoutPullRequest: Failed to check out the Bitbucket pull request.", + ); + assert.strictEqual(error.cause, gitCause); + }).pipe(Effect.provide(layer)); +}); + it.effect("checks out fork pull requests through an ensured fork remote", () => { const { git, layer } = makeLayer({ response: (request) => { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 43a1a705e67c..9a678ab44dc0 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -338,14 +338,6 @@ function authFromConfig( }; } -function requestError(operation: string, cause: unknown): BitbucketApiError { - return new BitbucketApiError({ - operation, - detail: cause instanceof Error ? cause.message : String(cause), - cause, - }); -} - function responseError( operation: string, response: HttpClientResponse.HttpClientResponse, @@ -412,7 +404,14 @@ export const make = Effect.gen(function* () { schema: S, ): Effect.Effect => httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( - Effect.mapError((cause) => requestError(operation, cause)), + Effect.mapError( + (cause) => + new BitbucketApiError({ + operation, + detail: "Failed to send the Bitbucket request.", + cause, + }), + ), Effect.flatMap((response) => decodeResponse(operation, schema, response)), ); @@ -746,7 +745,7 @@ export const make = Effect.gen(function* () { ? cause : new BitbucketApiError({ operation: "checkoutPullRequest", - detail: cause instanceof Error ? cause.message : String(cause), + detail: "Failed to check out the Bitbucket pull request.", cause, }), ), From 4d790f0064ab99287d3ccb17d2f003f5ed8e8d3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:13:27 -0700 Subject: [PATCH 38/66] [codex] Bound relay registration replay diagnostics (#3420) Co-authored-by: codex --- .../src/agentActivity/MobileRegistrations.test.ts | 14 ++++++++++++-- .../relay/src/agentActivity/MobileRegistrations.ts | 12 ++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 17a9c7bd417e..a223e9707c42 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -7,6 +7,7 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Redacted from "effect/Redacted"; import { FetchHttpClient } from "effect/unstable/http"; @@ -232,6 +233,11 @@ describe("MobileRegistrations", () => { }); it.effect("keeps device registration successful when activity replay fails", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + return Effect.gen(function* () { const result = yield* Effect.gen(function* () { const registrations = yield* MobileRegistrations.MobileRegistrations; @@ -250,7 +256,7 @@ describe("MobileRegistrations", () => { Effect.fail( new AgentActivityRows.AgentActivityRowListPersistenceError({ userId: "dev:julius", - cause: "replay failed", + cause: "sensitive device replay detail", }), ), }), @@ -262,7 +268,11 @@ describe("MobileRegistrations", () => { ); expect(result).toEqual({ ok: true }); - }); + expect(messages).toContainEqual([ + "device registration activity replay failed", + { errorTag: "AgentActivityRowListPersistenceError" }, + ]); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); it.effect("unregisters the current user's device", () => { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts index 395422b81dde..0df0379cdedb 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -51,8 +51,10 @@ export const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("device registration activity replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("device registration activity replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); @@ -70,8 +72,10 @@ export const make = Effect.gen(function* () { deviceId: input.payload.deviceId, }) .pipe( - Effect.tapError((cause) => - Effect.logWarning("live activity registration replay failed", { cause }), + Effect.tapError((error) => + Effect.logWarning("live activity registration replay failed", { + errorTag: error._tag, + }), ), Effect.ignore, ); From 4d3fcacd840d7b710f8415b4bebf3826df358c91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:18:38 -0700 Subject: [PATCH 39/66] [codex] Type malformed Clerk public config failures (#3422) Co-authored-by: codex --- apps/server/src/cloud/publicConfig.test.ts | 22 ++++++++++++++ apps/server/src/cloud/publicConfig.ts | 34 +++++++++++++++------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts index 4cce901fa55a..c46e2671a46e 100644 --- a/apps/server/src/cloud/publicConfig.test.ts +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -1,6 +1,7 @@ import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import { makeCloudCliOAuthConfig, @@ -88,6 +89,27 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va }).pipe(provideEnv({}), Effect.flip), ); +it.effect("reports malformed Clerk publishable keys as typed configuration failures", () => + Effect.gen(function* () { + const result = yield* makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "pk_test_not-base64!!", + clerkCliOAuthClientIdFallback: "oauth_client_embedded", + }).pipe(provideEnv({}), Effect.result); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.cause._tag, "SourceError"); + if (result.failure.cause._tag === "SourceError") { + assert.equal( + result.failure.cause.message, + "Failed to derive Clerk Frontend API URL from the publishable key.", + ); + assert.instanceOf(result.failure.cause.cause, Error); + } + } + }), +); + it("resolves relay client tracing from runtime config with build-time fallback", () => { const fallback = { tracesUrl: "https://embedded.example.test/v1/traces", diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index b344107d7564..176b31d7566a 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -1,6 +1,7 @@ import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -131,16 +132,29 @@ export function makeCloudCliOAuthConfig({ clerkCliOAuthClientIdFallback, ), }).pipe( - Config.map(({ clerkPublishableKey, clientId }) => { - const clerkFrontendApiUrl = clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey); - return { - authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, - tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, - clientId, - redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, - scopes: CLOUD_CLI_OAUTH_SCOPES, - } satisfies CloudCliOAuthConfig; - }), + Config.mapOrFail(({ clerkPublishableKey, clientId }) => + Effect.try({ + try: () => clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey), + catch: (cause) => + new Config.ConfigError( + new ConfigProvider.SourceError({ + message: "Failed to derive Clerk Frontend API URL from the publishable key.", + cause, + }), + ), + }).pipe( + Effect.map( + (clerkFrontendApiUrl) => + ({ + authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, + tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, + clientId, + redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, + scopes: CLOUD_CLI_OAUTH_SCOPES, + }) satisfies CloudCliOAuthConfig, + ), + ), + ), ); } From bfe61741b83dd7f2f66af7e7570ae0f67a16d631 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:11 -0700 Subject: [PATCH 40/66] [codex] Simplify desktop client settings errors (#3265) Co-authored-by: codex --- .../settings/DesktopClientSettings.test.ts | 5 +- .../src/settings/DesktopClientSettings.ts | 71 +++++++++++++------ 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 2d1d7fc547da..3584d6a21e42 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -117,11 +118,13 @@ describe("DesktopClientSettings", () => { assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); assert.equal(error.operation, "replace-settings-file"); assert.equal(error.path, environment.clientSettingsPath); - assert.exists(error.cause); + assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.isString(error.cause.stack); assert.equal( error.message, `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, ); + assert.notInclude(error.message, error.cause.message); }), ), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 585397d7502c..d08184f4ab76 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -25,7 +25,9 @@ const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); const decodeClientSettingsJson = (raw: string): Effect.Effect => decodeLegacyClientSettingsDocumentJson(raw).pipe( Effect.map((document) => document.settings), - Effect.catch(() => decodeClientSettingsJsonValue(raw)), + Effect.catchTags({ + SchemaError: () => decodeClientSettingsJsonValue(raw), + }), ); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); @@ -36,7 +38,6 @@ const DesktopClientSettingsWriteOperation = Schema.Literals([ "write-temporary-file", "replace-settings-file", ]); -type DesktopClientSettingsWriteOperation = typeof DesktopClientSettingsWriteOperation.Type; export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass()( "DesktopClientSettingsWriteError", @@ -51,13 +52,6 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass - new DesktopClientSettingsWriteError({ operation, path, cause }); - export class DesktopClientSettings extends Context.Service< DesktopClientSettings, { @@ -96,19 +90,45 @@ const writeClientSettings = Effect.fnUntraced(function* (input: { const directory = input.path.dirname(input.settingsPath); const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeClientSettingsJson(input.settings).pipe( - Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), ); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.settingsPath) - .pipe( - Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), - ); }); export const make = Effect.gen(function* () { @@ -124,8 +144,13 @@ export const make = Effect.gen(function* () { set: (settings) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.clientSettingsPath, cause), + Effect.mapError( + (cause) => + new DesktopClientSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.clientSettingsPath, + cause, + }), ), Effect.flatMap((suffix) => writeClientSettings({ From f98448e8721a491f5945f1857cbd22fa87a955ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:39 -0700 Subject: [PATCH 41/66] [codex] Structure relay JWT failures (#3270) Co-authored-by: codex --- infra/relay/src/auth/RelayTokens.ts | 15 +------ packages/shared/src/relayJwt.test.ts | 58 ++++++++++++++++++++++++++++ packages/shared/src/relayJwt.ts | 41 +++++++++++++++++--- 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 packages/shared/src/relayJwt.test.ts diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index 6c726ffa8269..bf48980907aa 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -11,9 +11,9 @@ import { import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { normalizeRelayIssuer, + RelayJwtError, signRelayJwt, verifyRelayJwt, - type RelayJwtError, } from "@t3tools/shared/relayJwt"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -72,17 +72,6 @@ const allowedScopesByClientId: Record< [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), }; -function relayJwtVerificationFailureReason(error: RelayJwtError): string { - const cause = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - const code = (cause as { readonly code?: unknown }).code; - if (typeof code === "string" && code.length > 0) { - return code; - } - } - return cause instanceof Error && cause.name ? cause.name : "unknown"; -} - function resolveDpopAccessTokenScopes(input: { readonly clientId: RelayPublicClientId; readonly scope: string; @@ -211,7 +200,7 @@ const make = Effect.gen(function* () { Effect.tapError((error) => Effect.annotateCurrentSpan( "relay.tokens.verification_failure", - relayJwtVerificationFailureReason(error), + RelayJwtError.diagnosticCode(error), ), ), Effect.flatMap(decodeDpopAccessTokenClaims), diff --git a/packages/shared/src/relayJwt.test.ts b/packages/shared/src/relayJwt.test.ts new file mode 100644 index 000000000000..4e863af484e2 --- /dev/null +++ b/packages/shared/src/relayJwt.test.ts @@ -0,0 +1,58 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { RelayJwtError, signRelayJwt, verifyRelayJwt } from "./relayJwt.ts"; + +describe("relayJwt", () => { + it.effect("preserves signing context and the JOSE cause", () => + Effect.gen(function* () { + const error = yield* signRelayJwt({ + privateKey: "not-a-private-key", + typ: "test-sign+jwt", + payload: { sub: "subject" }, + }).pipe(Effect.flip); + + expect(error.operation).toBe("sign"); + expect(error.typ).toBe("test-sign+jwt"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to sign relay JWT of type "test-sign+jwt".'); + }), + ); + + it.effect("preserves verification request context and the JOSE cause", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { format: "pem", type: "spki" }, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + }); + const error = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: "not-a-jwt", + typ: "test-verify+jwt", + issuer: "https://issuer.example.test", + audience: "test-audience", + nowEpochSeconds: 100, + }).pipe(Effect.flip); + + expect(error.operation).toBe("verify"); + expect(error.typ).toBe("test-verify+jwt"); + expect(error.issuer).toBe("https://issuer.example.test"); + expect(error.audience).toBe("test-audience"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to verify relay JWT of type "test-verify+jwt".'); + }), + ); + + it("extracts stable diagnostic codes without copying cause text into the error message", () => { + const error = new RelayJwtError({ + operation: "verify", + typ: "test+jwt", + cause: { code: "ERR_JWT_EXPIRED", message: "sensitive library detail" }, + }); + + expect(RelayJwtError.diagnosticCode(error)).toBe("ERR_JWT_EXPIRED"); + expect(error.message).not.toContain("sensitive library detail"); + }); +}); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index 20d55a530e3f..9e848bedfb02 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -1,7 +1,8 @@ import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; @@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; -export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ - readonly cause: unknown; -}> {} +export class RelayJwtError extends Schema.TaggedErrorClass()("RelayJwtError", { + operation: Schema.Literals(["sign", "verify"]), + typ: Schema.String, + issuer: Schema.optional(Schema.String), + audience: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} relay JWT of type "${this.typ}".`; + } + + static diagnosticCode(error: RelayJwtError): string { + if ( + Predicate.isObject(error.cause) && + Predicate.hasProperty(error.cause, "code") && + Predicate.isString(error.cause.code) && + error.cause.code.length > 0 + ) { + return error.cause.code; + } + + return error.cause instanceof Error && error.cause.name ? error.cause.name : "unknown"; + } +} export function normalizeRelayIssuer(value: string): string { return value.trim().replace(/\/+$/gu, ""); @@ -38,7 +60,7 @@ export function signRelayJwt(input: { .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) .sign(key); }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }), }); } @@ -65,6 +87,13 @@ export function verifyRelayJwt(input: { }); return verified.payload; }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => + new RelayJwtError({ + operation: "verify", + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + cause, + }), }); } From ed6ba7439d55b8c2cf7324d5107c2c1cb3ee0920 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:57 -0700 Subject: [PATCH 42/66] [codex] sanitize provider runtime failure diagnostics (#3414) Co-authored-by: codex --- .../src/provider/Layers/ClaudeProvider.ts | 16 +++++---- .../src/provider/Layers/CursorProvider.ts | 11 +++--- .../src/provider/Layers/GrokProvider.test.ts | 6 ++-- .../src/provider/Layers/GrokProvider.ts | 27 +++++++++------ .../provider/Layers/ProviderRegistry.test.ts | 13 ++++--- .../src/provider/Layers/ProviderService.ts | 6 ++-- .../src/provider/providerStatusCache.test.ts | 34 +++++++++++++++++++ .../src/provider/providerStatusCache.ts | 4 +-- 8 files changed, 85 insertions(+), 32 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d677de7a3138..bd5f7ebffc4c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -31,7 +31,6 @@ import { buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -661,6 +660,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; + yield* Effect.logWarning("Claude Agent CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -673,7 +675,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Claude Agent CLI health check.", }, }); } @@ -698,7 +700,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const version = versionProbe.success.value; const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); if (version.code !== 0) { - const detail = detailFromResult(version); + yield* Effect.logWarning("Claude Agent CLI version probe exited with a non-zero status.", { + exitCode: version.code, + stdoutLength: version.stdout.length, + stderrLength: version.stderr.length, + }); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -709,9 +715,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "error", auth: { status: "unknown" }, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", + message: "Claude Agent CLI is installed but failed to run.", }, }); } diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 94faac60647b..ff96ece93492 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -1006,6 +1006,9 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( if (Result.isFailure(aboutProbe)) { const error = aboutProbe.failure; + yield* Effect.logWarning("Cursor Agent CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: CURSOR_PRESENTATION, enabled: cursorSettings.enabled, @@ -1018,7 +1021,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." - : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Cursor Agent CLI health check.", }, }); } @@ -1074,7 +1077,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Cursor ACP model discovery failed", { - cause: Cause.pretty(discoveryExit.cause), + errorTag: causeErrorTag(discoveryExit.cause), }); discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; } else if (Option.isNone(discoveryExit.value)) { @@ -1130,7 +1133,7 @@ export const enrichCursorSnapshot = (input: { ), Effect.catchCause((cause) => Effect.logWarning("Cursor version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.asVoid), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565ed..000243869c9e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -54,6 +54,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => Effect.gen(function* () { + const secretStderr = "broken grok install: secret-token-value"; const snapshot = yield* Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -62,7 +63,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { const grokPath = path.join(dir, "grok"); yield* fs.writeFileString( grokPath, - ["#!/bin/sh", 'printf "%s\\n" "broken grok install" >&2', "exit 2", ""].join("\n"), + ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, "exit 2", ""].join("\n"), ); yield* fs.chmod(grokPath, 0o755); @@ -75,7 +76,8 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(true); expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("broken grok install"); + expect(snapshot.message).toBe("Grok CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index b1c84fb3a036..cf5d5ad9c8d8 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -6,7 +6,7 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -19,7 +19,6 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { buildServerProvider, - detailFromResult, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -195,6 +194,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func if (Result.isFailure(versionResult)) { const error = versionResult.failure; + yield* Effect.logWarning("Grok CLI health check failed.", { + errorTag: error._tag, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -207,7 +209,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Grok CLI (`grok`) is not installed or not on PATH." - : `Failed to execute Grok CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + : "Failed to execute Grok CLI health check.", }, }); } @@ -231,7 +233,11 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const versionOutput = versionResult.success.value; const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); if (versionOutput.code !== 0) { - const detail = detailFromResult(versionOutput); + yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -242,9 +248,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: detail - ? `Grok CLI is installed but failed to run. ${detail}` - : "Grok CLI is installed but failed to run.", + message: "Grok CLI is installed but failed to run.", }, }); } @@ -254,8 +258,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); if (Exit.isFailure(discoveryExit)) { - const detail = Cause.pretty(discoveryExit.cause); - yield* Effect.logWarning("Grok ACP model discovery failed", { cause: detail }); + yield* Effect.logWarning("Grok ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, @@ -266,7 +271,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func version, status: "error", auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup failed. ${detail}`, + message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", }, }); } @@ -324,7 +329,7 @@ export const enrichGrokSnapshot = (input: { Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), Effect.catchCause((cause) => Effect.logWarning("Grok version advisory enrichment failed", { - cause: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }), ), Effect.asVoid, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1805b6ed2772..b3ab11454956 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1875,14 +1875,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { + it.effect("returns error when version check fails with non-zero exit code", () => { + const secretStderr = "Something went wrong: secret-token-value"; + return Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), ); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); + assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); + assert.ok(!(status.message ?? "").includes(secretStderr)); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -1890,14 +1893,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te if (joined === "--version") return { stdout: "", - stderr: "Something went wrong", + stderr: secretStderr, code: 1, }; throw new Error(`Unexpected args: ${joined}`); }), ), - ), - ); + ); + }); it.effect("returns warning when the Claude initialization result is unavailable", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index c15d50eed628..2eaaeb8ce3c0 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -24,7 +24,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -1061,7 +1061,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.addFinalizer(() => runStopAll().pipe( Effect.catchCause((cause) => - Effect.logWarning("failed to stop provider service", { cause: Cause.pretty(cause) }), + Effect.logWarning("failed to stop provider service", { + errorTag: causeErrorTag(cause), + }), ), ), ); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 64cb9ccd4177..07f67cd7de8f 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -9,6 +9,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; import { hydrateCachedProvider, @@ -42,6 +43,39 @@ const makeProvider = ( }); it.layer(NodeServices.layer)("providerStatusCache", (it) => { + it.effect("logs structural diagnostics without retaining invalid cache contents", () => { + const messages: Array = []; + const logger = Logger.make((options) => { + if (Array.isArray(options.message)) { + messages.push(...options.message); + } else { + messages.push(options.message); + } + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); + const cachePath = `${tempDir}/provider.json`; + const secretCacheValue = "secret-cache-value"; + yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); + + const result = yield* readProviderStatusCache(cachePath); + + assert.strictEqual(result, undefined); + const failure = messages.find( + (message): message is Record => + typeof message === "object" && message !== null && "path" in message, + ); + assert.exists(failure); + assert.strictEqual(failure.path, cachePath); + assert.strictEqual(typeof failure.errorTag, "string"); + assert.ok(!("cause" in failure)); + assert.ok(!("issues" in failure)); + assert.ok(!Object.values(failure).map(String).join("\n").includes(secretCacheValue)); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 0b9b365f360a..2fe0424b4f57 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -4,7 +4,7 @@ import { type ServerProvider, ServerProvider as ServerProviderSchema, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; +import { causeErrorTag } from "@t3tools/shared/observability"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -134,7 +134,7 @@ export const readProviderStatusCache = (filePath: string) => onFailure: (cause) => Effect.logWarning("failed to parse provider status cache, ignoring", { path: filePath, - issues: Cause.pretty(cause), + errorTag: causeErrorTag(cause), }).pipe(Effect.as(undefined)), onSuccess: Effect.succeed, }), From 1e5f62801b7c4d247247bc3648310a20560e5b43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:20:14 -0700 Subject: [PATCH 43/66] [codex] Structure MCP snapshot failures (#3423) Co-authored-by: codex --- apps/server/src/mcp/McpHttpServer.test.ts | 56 +++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 65 ++++++++++++++++------- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 210bb7e5ad8a..f550396c6602 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -49,6 +49,62 @@ it("normalizes empty successful notification responses to accepted", () => { expect(resultResponse.status).toBe(200); }); +it.effect("returns bounded structural preview snapshot failures", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const requests = yield* broker.connect({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message: "sensitive renderer failure", + detail: { consoleOutput: "sensitive browser output" }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* broker.reportOwner({ + clientId: "mcp-failure-client", + environmentId, + threadId, + tabId, + visible: true, + supportsAutomation: true, + focusedAt: "2026-06-11T00:00:00.000Z", + }); + + const snapshot = yield* server + .callTool({ name: "preview_snapshot", arguments: {} }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); + expect(snapshot.structuredContent).toEqual({ + error: { + _tag: "PreviewAutomationExecutionError", + operation: "snapshot", + failureCount: 1, + }, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 6cde2017a9e4..e95662a30f89 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -88,6 +88,37 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; +const previewSnapshotFailure = (cause: Cause.Cause) => { + if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { + return Effect.failCause(cause).pipe(Effect.orDie); + } + const failures = cause.reasons.filter(Cause.isFailReason); + const firstFailure = failures[0]?.error; + const errorTag = + typeof firstFailure === "object" && + firstFailure !== null && + "_tag" in firstFailure && + typeof firstFailure._tag === "string" + ? firstFailure._tag + : "PreviewSnapshotError"; + const result = new McpSchema.CallToolResult({ + isError: true, + structuredContent: { + error: { + _tag: errorTag, + operation: "snapshot", + failureCount: failures.length, + }, + }, + content: [{ type: "text", text: "Preview snapshot failed." }], + }); + return Effect.logWarning("preview snapshot failed", { + operation: "snapshot", + errorTag, + failureCount: failures.length, + }).pipe(Effect.as(result)); +}; + const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -122,12 +153,8 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCause({ - onFailure: (cause) => - new McpSchema.CallToolResult({ - isError: true, - content: [{ type: "text", text: Cause.pretty(cause) }], - }), + Effect.matchCauseEffect({ + onFailure: previewSnapshotFailure, onSuccess: ({ encodedResult }) => { const snapshot = encodedResult as { readonly screenshot: { @@ -147,18 +174,20 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot height: screenshot.height, }, }; - return new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }); + return Effect.succeed( + new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + { + type: "image", + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ], + }), + ); }, }), ); From 1486a4a2b9c3f5a83d54f5ce591a2f60fba9316f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:20:46 -0700 Subject: [PATCH 44/66] [codex] Structure Electron updater errors (#3280) Co-authored-by: codex --- .../src/electron/ElectronUpdater.test.ts | 66 ++++++++++++++++--- apps/desktop/src/electron/ElectronUpdater.ts | 58 ++++++++++------ 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 43a3c84dcd4a..8fcc34f41c24 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,5 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vite-plus/test"; @@ -65,16 +64,65 @@ describe("ElectronUpdater", () => { const cause = new Error("network unavailable"); autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "beta"; - const exit = yield* Effect.exit(updater.checkForUpdates); + const error = yield* updater.checkForUpdates.pipe(Effect.flip); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.equal(error.cause, cause); - assert.equal(error.message, "Electron updater failed to check for updates."); - } + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "beta"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves the execution-time channel on download failures", () => + Effect.gen(function* () { + const cause = new Error("download unavailable"); + autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "nightly"; + + const error = yield* updater.downloadUpdate.pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to download the update on channel nightly.", + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("preserves quit-and-install flags and the execution-time channel", () => + Effect.gen(function* () { + const cause = new Error("quit and install failed"); + autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { + throw cause; + }); + const updater = yield* ElectronUpdater.ElectronUpdater; + autoUpdaterMock.channel = "alpha"; + + const error = yield* updater + .quitAndInstall({ isSilent: true, isForceRunAfter: false }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); + assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); + assert.equal(error.channel, "alpha"); + assert.equal(error.isSilent, true); + assert.equal(error.isForceRunAfter, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", + ); + assert.notInclude(error.message, cause.message); + assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 8a468a15c20e..435fbd002289 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -10,40 +10,41 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; -const electronUpdaterErrorFields = { - cause: Schema.Defect(), -}; - export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", { - ...electronUpdaterErrorFields, + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return "Electron updater failed to check for updates."; + return `Electron updater failed to check for updates on channel ${this.channel ?? "default"}.`; } } export class ElectronUpdaterDownloadUpdateError extends Schema.TaggedErrorClass()( "ElectronUpdaterDownloadUpdateError", { - ...electronUpdaterErrorFields, + channel: Schema.NullOr(Schema.String), + cause: Schema.Defect(), }, ) { override get message(): string { - return "Electron updater failed to download the update."; + return `Electron updater failed to download the update on channel ${this.channel ?? "default"}.`; } } export class ElectronUpdaterQuitAndInstallError extends Schema.TaggedErrorClass()( "ElectronUpdaterQuitAndInstallError", { - ...electronUpdaterErrorFields, + channel: Schema.NullOr(Schema.String), + isSilent: Schema.Boolean, + isForceRunAfter: Schema.Boolean, + cause: Schema.Defect(), }, ) { override get message(): string { - return "Electron updater failed to quit and install the update."; + return `Electron updater failed to quit and install the update on channel ${this.channel ?? "default"} (silent: ${this.isSilent}, force run after: ${this.isForceRunAfter}).`; } } @@ -116,18 +117,33 @@ export const make = ElectronUpdater.of({ autoUpdater.disableDifferentialDownload = value; return Effect.void; }), - checkForUpdates: Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), - }).pipe(Effect.asVoid), - downloadUpdate: Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), - }).pipe(Effect.asVoid), + checkForUpdates: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), + }).pipe(Effect.asVoid); + }), + downloadUpdate: Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), + }).pipe(Effect.asVoid); + }), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), + Effect.suspend(() => { + const channel = autoUpdater.channel; + return Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => + new ElectronUpdaterQuitAndInstallError({ + channel, + isSilent, + isForceRunAfter, + cause, + }), + }); }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { From c3e3e26844a591f2bb21e617db3c5494e700fbcc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:22:28 -0700 Subject: [PATCH 45/66] [codex] Structure primary environment request failures (#3409) Co-authored-by: codex --- apps/web/src/authBootstrap.test.ts | 38 +++-- apps/web/src/environments/primary/auth.ts | 154 +++++++++++-------- apps/web/src/environments/primary/context.ts | 14 +- apps/web/src/environments/primary/index.ts | 2 + 4 files changed, 127 insertions(+), 81 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 53c17c06402c..c0713bfc059d 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -290,22 +290,38 @@ describe("resolveInitialServerAuthGateState", () => { }); it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); const testApi = await installAuthApi({ - browserSession: () => - Effect.fail( - new EnvironmentAuthInvalidError({ - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", - }), - ), + browserSession: () => Effect.fail(cause), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + const { isPrimaryEnvironmentRequestError, submitServerAuthCredential } = + await import("./environments/primary"); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentRequestError", + operation: "exchange-bootstrap-credential", + status: 401, + detail: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentRequestError(error)).toBe(true); + if (!isPrimaryEnvironmentRequestError(error)) { + throw new Error("Expected a structured primary environment request error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); }); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303c..5cf7d2d34b71 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,57 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + detail: Schema.String, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly fallbackMessage: (status: number) => string; + readonly formatDetail?: (detail: string, status: number) => string; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + const rawDetail = readHttpApiErrorMessage(input.cause, input.fallbackMessage(status)); + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + detail: input.formatDetail?.(rawDetail, status) ?? rawDetail, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return this.detail; + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +148,10 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, + fallbackMessage: (status) => `Failed to load server auth session state (${status}).`, }); } }); @@ -183,11 +225,11 @@ async function exchangeBootstrapCredential(credential: string): Promise `Failed to bootstrap auth session (${status}).`, + formatDetail: (detail, status) => toFriendlyBootstrapErrorMessage(status, detail), }); } }); @@ -240,7 +282,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -310,13 +352,11 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + fallbackMessage: (status) => `Failed to create pairing credential (${status}).`, + }); } } @@ -353,13 +393,11 @@ export async function listServerPairingLinks(): Promise `Failed to load pairing links (${status}).`, + }); } } @@ -371,13 +409,12 @@ export async function revokeServerPairingLink(id: string): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + fallbackMessage: (status) => `Failed to revoke pairing link (${status}).`, + }); } } @@ -406,13 +443,11 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to load paired clients (${status}).`, + }); } } @@ -426,13 +461,12 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + fallbackMessage: (status) => `Failed to revoke client session (${status}).`, + }); } } @@ -445,13 +479,11 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + fallbackMessage: (status) => `Failed to revoke other client sessions (${status}).`, + }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index eb818e8f558a..40b6f68bd090 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -5,9 +5,8 @@ import { } from "@t3tools/client-runtime/environment"; import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; @@ -44,13 +43,10 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, + fallbackMessage: (status) => `Failed to load server environment descriptor (${status}).`, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 305ced9c905b..3cb570d66abb 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -16,9 +16,11 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession, From 5ca7676661325b13ee2a557b28f0d0d3ce8ab695 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:22:57 -0700 Subject: [PATCH 46/66] [codex] Structure Claude adapter failures (#3406) Co-authored-by: codex --- .../src/provider/Layers/ClaudeAdapter.test.ts | 116 +++++++++++++++--- .../src/provider/Layers/ClaudeAdapter.ts | 95 +++++++------- 2 files changed, 143 insertions(+), 68 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4d22a2c1f8d9..191bf8e27db9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -35,7 +35,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderAdapterValidationError } from "../Errors.ts"; +import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -298,6 +298,44 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("retains Claude session startup causes without exposing their messages", () => { + const cause = new Error("credential material that must remain in the cause chain"); + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + throw cause; + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const error = yield* adapter + .startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ProviderAdapterProcessError); + assert.equal(error.detail, "Failed to start Claude runtime session."); + assert.strictEqual(error.cause, cause); + assert.notMatch(error.message, /credential material/u); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("derives bypass permission mode from full-access runtime policy", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1365,19 +1403,14 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -1430,6 +1463,57 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps Claude stream failure events structural", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.fail(new Error("credential material that must stay in the cause chain")); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + runtimeEventsFiber.interruptUnsafe(); + + const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error"); + assert.equal(runtimeError?.type, "runtime.error"); + if (runtimeError?.type === "runtime.error") { + assert.equal(runtimeError.payload.message, "Claude runtime stream failed."); + assert.deepEqual(runtimeError.payload.detail, { + failureCount: 1, + failureTags: ["ProviderAdapterProcessError"], + }); + } + + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.state, "failed"); + assert.equal(completed.payload.errorMessage, "Claude runtime stream failed."); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the previous session before replacing an existing thread session", () => { const queries: FakeClaudeQuery[] = []; const layer = Layer.effect( @@ -1542,14 +1626,12 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = runFork( - Stream.runForEach(adapter.streamEvents, () => Effect.void), - ); + const runtimeEventsFiber = yield* Stream.runForEach( + adapter.streamEvents, + () => Effect.void, + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c91f305b174c..97a93f85829c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -249,21 +249,8 @@ function toMessage(cause: unknown, fallback: string): string { return fallback; } -function toProcessError( - cause: unknown, - fallback: string, - threadId: ThreadId, -): ProviderAdapterProcessError { - return new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: toMessage(cause, fallback), - cause, - }); -} - function normalizeClaudeStreamMessages( - cause: Cause.Cause<{ readonly message: string }>, + cause: Cause.Cause, ): ReadonlyArray { const errors: Array = []; for (const error of Cause.prettyErrors(cause)) { @@ -297,27 +284,17 @@ function isClaudeInterruptedMessage(message: string): boolean { ); } -function isClaudeInterruptedCause(cause: Cause.Cause<{ readonly message: string }>): boolean { +function isClaudeInterruptedCause(cause: Cause.Cause): boolean { return ( Cause.hasInterruptsOnly(cause) || - normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) + normalizeClaudeStreamMessages(cause).some(isClaudeInterruptedMessage) || + cause.reasons.some( + (reason) => + Cause.isFailReason(reason) && isClaudeInterruptedMessage(toMessage(reason.error.cause, "")), + ) ); } -function messageFromClaudeStreamCause( - cause: Cause.Cause<{ readonly message: string }>, - fallback: string, -): string { - return normalizeClaudeStreamMessages(cause)[0] ?? fallback; -} - -function interruptionMessageFromClaudeCause( - cause: Cause.Cause<{ readonly message: string }>, -): string { - const message = messageFromClaudeStreamCause(cause, "Claude runtime interrupted."); - return isClaudeInterruptedMessage(message) ? "Claude runtime interrupted." : message; -} - function resultErrorsText(result: SDKResultMessage): string { return "errors" in result && Array.isArray(result.errors) ? result.errors.join(" ").toLowerCase() @@ -1004,7 +981,7 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/start", - detail: toMessage(cause, "Failed to read attachment file."), + detail: "Failed to read attachment file.", cause, }), ), @@ -1242,7 +1219,7 @@ function toRequestError(threadId: ThreadId, method: string, cause: unknown): Pro return new ProviderAdapterRequestError({ provider: PROVIDER, method, - detail: toMessage(cause, `${method} failed`), + detail: `${method} failed`, cause, }); } @@ -2910,18 +2887,27 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const runSdkStream = ( context: ClaudeSessionContext, ): Effect.Effect => - Stream.fromAsyncIterable(context.query, (cause) => - toProcessError(cause, "Claude runtime stream failed.", context.session.threadId), + Stream.fromAsyncIterable( + context.query, + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Claude runtime stream failed.", + cause, + }), ).pipe( Stream.takeWhile(() => !context.stopped), Stream.runForEach((message) => handleSdkMessage(context, message).pipe( - Effect.mapError((cause) => - toProcessError( - cause, - "Failed to process Claude runtime event.", - context.session.threadId, - ), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to process Claude runtime event.", + cause, + }), ), ), ), @@ -2938,15 +2924,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (Exit.isFailure(exit)) { if (isClaudeInterruptedCause(exit.cause)) { if (context.turnState) { - yield* completeTurn( - context, - "interrupted", - interruptionMessageFromClaudeCause(exit.cause), - ); + yield* completeTurn(context, "interrupted", "Claude runtime interrupted."); } } else { - const message = messageFromClaudeStreamCause(exit.cause, "Claude runtime stream failed."); - yield* emitRuntimeError(context, message, Cause.pretty(exit.cause)); + const failures = exit.cause.reasons.flatMap((reason) => + Cause.isFailReason(reason) ? [reason.error] : [], + ); + const message = failures[0]?.detail ?? "Claude runtime stream failed."; + yield* emitRuntimeError(context, message, { + failureCount: failures.length, + failureTags: failures.map((failure) => failure._tag), + }); yield* completeTurn(context, "failed", message); } } else if (context.turnState) { @@ -3004,12 +2992,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId: context.session.threadId, - detail: toMessage(cause, "Failed to close Claude runtime query."), + detail: "Failed to close Claude runtime query.", cause, }), }).pipe( - Effect.catch((cause) => - emitRuntimeError(context, "Failed to close Claude runtime query.", cause), + Effect.catch((error) => + emitRuntimeError(context, "Failed to close Claude runtime query.", { + errorTag: error._tag, + provider: error.provider, + threadId: error.threadId, + detail: error.detail, + }), ), ); @@ -3522,7 +3515,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: toMessage(cause, "Failed to start Claude runtime session."), + detail: "Failed to start Claude runtime session.", cause, }), }); From 49c23221d80c68114fe4d88e92b05fa1129de0d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:23:58 -0700 Subject: [PATCH 47/66] [codex] Structure mobile secure storage failures (#3345) Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 31 ++++++++++ apps/mobile/src/lib/storage.ts | 95 +++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index c3dd28ac3a1b..084f9430d084 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -69,4 +69,35 @@ describe("mobile connection storage", () => { toStableSavedRemoteConnection(managedConnection), ]); }); + + it("preserves secure-storage read failures with operation and key context", async () => { + const cause = new Error("keychain unavailable"); + mocks.getItemAsync.mockRejectedValueOnce(cause); + + await expect(loadSavedConnections()).rejects.toMatchObject({ + _tag: "MobileSecureStorageError", + operation: "read", + key: "t3code.connections", + cause, + message: "Mobile secure storage operation read failed for key t3code.connections.", + }); + }); + + it("logs structured decode failures before using the empty fallback", async () => { + await mocks.setItemAsync("t3code.connections", "{"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadSavedConnections()).resolves.toEqual([]); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] ignored invalid JSON", + expect.objectContaining({ + _tag: "MobileStorageDecodeError", + key: "t3code.connections", + cause: expect.any(SyntaxError), + message: "Failed to decode mobile storage value for key t3code.connections.", + }), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index da54f92949bd..114648277b92 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,5 +1,6 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; import { EnvironmentId } from "@t3tools/contracts"; @@ -12,21 +13,72 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const MobileStorageKey = Schema.Literals([ + CONNECTIONS_KEY, + PREFERENCES_KEY, + AGENT_AWARENESS_DEVICE_ID_KEY, +]); +type MobileStorageKeyValue = typeof MobileStorageKey.Type; + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: Schema.Literals(["read", "write", "generate-device-id"]), + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } +} + +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } +} + +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: MobileStorageKey, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } +} export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } -async function readStorageItem(key: string): Promise { - return await SecureStore.getItemAsync(key); +async function readStorageItem(key: MobileStorageKeyValue): Promise { + try { + return await SecureStore.getItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "read", key, cause }); + } } -async function writeStorageItem(key: string, value: string): Promise { - await SecureStore.setItemAsync(key, value); +async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { + try { + await SecureStore.setItemAsync(key, value); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "write", key, cause }); + } } -async function readJsonStorageItem(key: string): Promise { +async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { const raw = (await readStorageItem(key)) ?? ""; if (!raw.trim()) { return null; @@ -34,11 +86,25 @@ async function readJsonStorageItem(key: string): Promise { try { return JSON.parse(raw) as T; - } catch { + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); return null; } } +async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { + let encoded: string; + try { + encoded = JSON.stringify(value); + } catch (cause) { + throw new MobileStorageEncodeError({ key, cause }); + } + await writeStorageItem(key, encoded); +} + export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; @@ -67,7 +133,7 @@ export async function saveConnection(connection: SavedRemoteConnection): Promise ) : pipe(current, Arr.append(stableConnection)); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function clearSavedConnection(environmentId: EnvironmentId): Promise { @@ -76,7 +142,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis current, Arr.filter((entry) => entry.environmentId !== environmentId), ); - await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); + await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } export async function loadPreferences(): Promise { @@ -106,7 +172,7 @@ export async function savePreferencesPatch(patch: Partial): Promise ...current, ...patch, }; - await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + await writeJsonStorageItem(PREFERENCES_KEY, next); return next; } @@ -116,8 +182,15 @@ export async function loadOrCreateAgentAwarenessDeviceId(): Promise { return existing; } - const { uuidv4 } = await import("./uuid"); - const deviceId = uuidv4(); + const deviceId = await import("./uuid") + .then(({ uuidv4 }) => uuidv4()) + .catch((cause) => { + throw new MobileSecureStorageError({ + operation: "generate-device-id", + key: AGENT_AWARENESS_DEVICE_ID_KEY, + cause, + }); + }); await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); return deviceId; } From 2b8e012924063e185786c8ff120b6fd2886b3aa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:24:35 -0700 Subject: [PATCH 48/66] [codex] Structure desktop server exposure errors (#3269) Co-authored-by: codex --- .../src/backend/DesktopServerExposure.test.ts | 66 ++++++++++++++++++- .../src/backend/DesktopServerExposure.ts | 60 ++++++++++++----- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 6bfe2e097aeb..8b934fd8d85c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -91,6 +91,7 @@ function makeLayer(input: { readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces; readonly env?: Record; readonly spawnerLayer?: Layer.Layer; + readonly desktopSettingsLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const environmentLayer = makeEnvironmentLayer(input.baseDir, env); @@ -99,7 +100,7 @@ function makeLayer(input: { }); return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), @@ -122,6 +123,7 @@ const withHarness = ( >, env: Record = {}, spawnerLayer?: Layer.Layer, + desktopSettingsLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -135,6 +137,7 @@ const withHarness = ( networkInterfaces, env, ...(spawnerLayer ? { spawnerLayer } : {}), + ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), }), ), ); @@ -237,6 +240,67 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("preserves persistence request context and the settings failure chain", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/desktop-settings.json", + cause: diskFailure, + }); + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.fail(settingsFailure), + setTailscaleServe: () => Effect.fail(settingsFailure), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + return withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.instanceOf( + modeError, + DesktopServerExposure.DesktopServerExposureModePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); + assert.equal(modeError.mode, "network-accessible"); + assert.strictEqual(modeError.cause, settingsFailure); + assert.strictEqual(modeError.cause.cause, diskFailure); + assert.equal( + modeError.message, + "Failed to persist desktop server exposure mode network-accessible.", + ); + assert.notInclude(modeError.message, diskFailure.message); + + const tailscaleError = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 8443 }) + .pipe(Effect.flip); + assert.instanceOf( + tailscaleError, + DesktopServerExposure.DesktopTailscaleServePersistenceError, + ); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); + assert.equal(tailscaleError.enabled, true); + assert.equal(tailscaleError.port, 8443); + assert.strictEqual(tailscaleError.cause, settingsFailure); + assert.strictEqual(tailscaleError.cause.cause, diskFailure); + assert.equal( + tailscaleError.message, + "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", + ); + assert.notInclude(tailscaleError.message, diskFailure.message); + }), + {}, + undefined, + settingsLayer, + ); + }); + it.effect("resolves advertised endpoints from the scoped runtime state", () => withHarness( { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 64e65a61c77e..f04d2af7b1f6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -2,11 +2,12 @@ import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, } from "@t3tools/shared/advertisedEndpoint"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, - DesktopServerExposureState, +import { + DesktopServerExposureModeSchema, + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, + type DesktopServerExposureMode, + type DesktopServerExposureState, } from "@t3tools/contracts"; import { readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; @@ -213,23 +214,45 @@ export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErr } } -export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass()( - "DesktopServerExposurePersistenceError", +export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass()( + "DesktopServerExposureModePersistenceError", { - operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]), + mode: DesktopServerExposureModeSchema, cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), }, ) { override get message(): string { - return `Failed to persist desktop ${this.operation} settings.`; + return `Failed to persist desktop server exposure mode ${this.mode}.`; } } -export type DesktopServerExposureSetModeError = - | DesktopServerExposureNoNetworkAddressError - | DesktopServerExposurePersistenceError; +export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorClass()( + "DesktopTailscaleServePersistenceError", + { + enabled: Schema.Boolean, + port: Schema.NullOr(Schema.Number), + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`; + } +} -export type DesktopServerExposureError = DesktopServerExposureSetModeError; +export const DesktopServerExposureSetModeError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, +]); +export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; +export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); + +export const DesktopServerExposureError = Schema.Union([ + DesktopServerExposureNoNetworkAddressError, + DesktopServerExposureModePersistenceError, + DesktopTailscaleServePersistenceError, +]); +export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; +export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -258,7 +281,7 @@ export class DesktopServerExposure extends Context.Service< readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} @@ -449,8 +472,8 @@ export const make = Effect.gen(function* () { const change = yield* desktopSettings.setServerExposureMode(mode).pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "server-exposure-mode", + new DesktopServerExposureModePersistenceError({ + mode, cause, }), ), @@ -477,8 +500,9 @@ export const make = Effect.gen(function* () { .pipe( Effect.mapError( (cause) => - new DesktopServerExposurePersistenceError({ - operation: "tailscale-serve", + new DesktopTailscaleServePersistenceError({ + enabled: input.enabled, + port: input.port ?? null, cause, }), ), From 2910d9ff0106d06f8fc1c55a44787385118f595a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:25:14 -0700 Subject: [PATCH 49/66] [codex] Structure preview config failures (#3271) Co-authored-by: codex --- .../browser/previewWebviewConfigState.test.ts | 58 +++++++++++++++ .../src/browser/previewWebviewConfigState.ts | 70 +++++++++++++------ 2 files changed, 107 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/browser/previewWebviewConfigState.test.ts diff --git a/apps/web/src/browser/previewWebviewConfigState.test.ts b/apps/web/src/browser/previewWebviewConfigState.test.ts new file mode 100644 index 000000000000..35eb665eb7e3 --- /dev/null +++ b/apps/web/src/browser/previewWebviewConfigState.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + loadPreviewWebviewConfig, + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +} from "./previewWebviewConfigState"; + +const environmentId = EnvironmentId.make("environment-1"); + +describe("loadPreviewWebviewConfig", () => { + it.effect("reports a structurally distinct missing-bridge failure", () => + Effect.gen(function* () { + const error = yield* loadPreviewWebviewConfig(environmentId, null).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewBridgeUnavailableError); + expect(error.environmentId).toBe(environmentId); + expect(error.message).toContain(environmentId); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("preserves the bridge rejection as the load failure cause", () => + Effect.gen(function* () { + const cause = new Error("ipc unavailable"); + const error = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: () => Promise.reject(cause), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewWebviewConfigLoadError); + expect(error.environmentId).toBe(environmentId); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain(cause.message); + }), + ); + + it.effect("forwards the environment id to the bridge", () => + Effect.gen(function* () { + let requestedEnvironmentId: EnvironmentId | null = null; + const config = { + partition: "persist:test-preview", + webPreferences: "sandbox=yes", + preloadUrl: null, + }; + const result = yield* loadPreviewWebviewConfig(environmentId, { + getPreviewConfig: (input) => { + requestedEnvironmentId = input; + return Promise.resolve(config); + }, + }); + + expect(requestedEnvironmentId).toBe(environmentId); + expect(result).toEqual(config); + }), + ); +}); diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 99a8388ec5a7..6f1cf058e38c 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -1,8 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; -import type { DesktopPreviewWebviewConfig, EnvironmentId } from "@t3tools/contracts"; -import * as Data from "effect/Data"; +import type { + DesktopPreviewBridge, + DesktopPreviewWebviewConfig, + EnvironmentId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; @@ -10,27 +14,51 @@ import { previewBridge } from "~/components/preview/previewBridge"; const PREVIEW_CONFIG_STALE_TIME_MS = 5 * 60_000; const PREVIEW_CONFIG_IDLE_TTL_MS = 10 * 60_000; -class PreviewWebviewConfigError extends Data.TaggedError("PreviewWebviewConfigError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PreviewWebviewBridgeUnavailableError extends Schema.TaggedErrorClass()( + "PreviewWebviewBridgeUnavailableError", + { environmentId: Schema.String }, +) { + override get message(): string { + return `Desktop preview configuration is unavailable for environment "${this.environmentId}".`; + } +} + +export class PreviewWebviewConfigLoadError extends Schema.TaggedErrorClass()( + "PreviewWebviewConfigLoadError", + { + environmentId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to load desktop preview configuration for environment "${this.environmentId}".`; + } +} + +export const PreviewWebviewConfigError = Schema.Union([ + PreviewWebviewBridgeUnavailableError, + PreviewWebviewConfigLoadError, +]); +export type PreviewWebviewConfigError = typeof PreviewWebviewConfigError.Type; + +type PreviewConfigBridge = Pick; + +export const loadPreviewWebviewConfig = ( + environmentId: EnvironmentId, + bridge: PreviewConfigBridge | null = previewBridge, +): Effect.Effect => { + if (bridge === null) { + return Effect.fail(new PreviewWebviewBridgeUnavailableError({ environmentId })); + } + + return Effect.tryPromise({ + try: () => bridge.getPreviewConfig(environmentId), + catch: (cause) => new PreviewWebviewConfigLoadError({ environmentId, cause }), + }); +}; const previewWebviewConfigAtom = Atom.family((environmentId: EnvironmentId) => - Atom.make( - Effect.tryPromise({ - try: () => { - if (!previewBridge) { - throw new Error("Desktop preview bridge is unavailable."); - } - return previewBridge.getPreviewConfig(environmentId); - }, - catch: (cause) => - new PreviewWebviewConfigError({ - message: "Could not load desktop preview configuration.", - cause, - }), - }), - ).pipe( + Atom.make(loadPreviewWebviewConfig(environmentId)).pipe( Atom.swr({ staleTime: PREVIEW_CONFIG_STALE_TIME_MS, revalidateOnMount: true, From 48f88d522047198a660cacebbde854a617068720 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:26:03 -0700 Subject: [PATCH 50/66] [codex] Structure desktop Clerk bridge failures (#3308) Co-authored-by: codex --- apps/desktop/src/app/DesktopClerk.test.ts | 79 +++++++++++++++++++---- apps/desktop/src/app/DesktopClerk.ts | 50 +++++++++++++- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index a80a9fe24fb0..9b5ed56d1f34 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,7 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ createClerkBridgeMock: vi.fn(), @@ -24,7 +25,23 @@ vi.mock("@clerk/electron/storage", () => ({ import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +const makeDesktopClerkLayer = (isDevelopment = true) => { + const environment = DesktopEnvironment.DesktopEnvironment.of({ + stateDir: "/tmp/t3-state", + isDevelopment, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + + return DesktopClerk.layer.pipe( + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + ); +}; + describe("DesktopClerk", () => { + beforeEach(() => { + createClerkBridgeMock.mockReset(); + storageMock.mockReset(); + }); + it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; @@ -40,19 +57,9 @@ describe("DesktopClerk", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup }); - const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", - isDevelopment: true, - } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); return Effect.gen(function* () { - yield* Effect.scoped( - Layer.build( - DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), - ), - ), - ); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -69,6 +76,54 @@ describe("DesktopClerk", () => { }); }); + it.effect("preserves bridge initialization failures", () => { + const cause = new Error("bridge initialization failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockImplementationOnce(() => { + throw cause; + }); + + return Effect.gen(function* () { + const error = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe(Effect.flip); + + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, true); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', + ); + }); + }); + + it.effect("preserves bridge cleanup failures", () => { + const cause = new Error("bridge cleanup failed"); + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ + cleanup: () => { + throw cause; + }, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(Effect.scoped(Layer.build(makeDesktopClerkLayer(false)))); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); + assert.equal(error.stateDir, "/tmp/t3-state"); + assert.equal(error.isDevelopment, false); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', + ); + } + }); + }); + it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 1fa5640b2eea..0e283f8dd0c4 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; @@ -14,6 +15,32 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; +export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeInitializationError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + +export class DesktopClerkBridgeCleanupError extends Schema.TaggedErrorClass()( + "DesktopClerkBridgeCleanupError", + { + stateDir: Schema.String, + isDevelopment: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; + } +} + export class DesktopClerk extends Context.Service< DesktopClerk, { @@ -55,11 +82,28 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea }); } -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* Effect.acquireRelease( - Effect.sync(() => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment)), - (bridge) => Effect.sync(() => bridge.cleanup()), + Effect.try({ + try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), + catch: (cause) => + new DesktopClerkBridgeInitializationError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }), + (bridge) => + Effect.try({ + try: () => bridge.cleanup(), + catch: (cause) => + new DesktopClerkBridgeCleanupError({ + stateDir: environment.stateDir, + isDevelopment: environment.isDevelopment, + cause, + }), + }).pipe(Effect.orDie), ); return DesktopClerk.of({ From 9c98cd60e866fb576e7029cd1152f75aca06452c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:26:51 -0700 Subject: [PATCH 51/66] Remove persistence error constructor wrappers (#3398) Co-authored-by: codex --- .../src/persistence/AuthPairingLinks.ts | 8 +-- apps/server/src/persistence/AuthSessions.ts | 8 +-- apps/server/src/persistence/Errors.test.ts | 49 +++++++++++++++++++ apps/server/src/persistence/Errors.ts | 47 +++++++++++------- .../src/persistence/ProviderSessionRuntime.ts | 26 ++++++---- 5 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 apps/server/src/persistence/Errors.test.ts diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index add90f048031..c29b023d1d88 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -10,8 +10,8 @@ import { AuthEnvironmentScopes } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + PersistenceSqlError, } from "./Errors.ts"; export const AuthPairingLinkRecord = Schema.Struct({ @@ -90,8 +90,8 @@ export class AuthPairingLinkRepository extends Context.Service< function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } export const make = Effect.gen(function* () { diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index e3e8a19f5d0f..17f76042d0ab 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -15,8 +15,8 @@ import { import { type AuthSessionRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + PersistenceSqlError, } from "./Errors.ts"; export const AuthSessionClientMetadataRecord = Schema.Struct({ @@ -146,8 +146,8 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } export const make = Effect.gen(function* () { diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts new file mode 100644 index 000000000000..680a362e20a3 --- /dev/null +++ b/apps/server/src/persistence/Errors.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; + +const decodeRuntimePayload = Schema.decodeUnknownEffect( + Schema.Struct({ + runtimePayload: Schema.Struct({ + attempt: Schema.Number, + }), + }), +); + +it("keeps SQL operation context without a tautological detail", () => { + const cause = new Error("database unavailable"); + const error = new PersistenceSqlError({ + operation: "AuthSessionRepository.list:query", + cause, + }); + + assert.equal(error.operation, "AuthSessionRepository.list:query"); + assert.equal(error.detail, undefined); + assert.equal(error.cause, cause); + assert.equal(error.message, "SQL error in AuthSessionRepository.list:query"); +}); + +it.effect("maps schema errors without copying rejected payloads into diagnostics", () => + Effect.gen(function* () { + const rejectedPayload = "runtime-payload-secret-sentinel"; + const cause = yield* Effect.flip( + decodeRuntimePayload({ + runtimePayload: { + attempt: rejectedPayload, + }, + }), + ); + const error = PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + ); + + assert.equal(error.operation, "ProviderSessionRuntimeRepository.list:decodeRows"); + assert.equal(error.cause, cause); + assert.notInclude(error.issue, rejectedPayload); + assert.notInclude(error.message, rejectedPayload); + assert.include(error.issue, "InvalidType"); + }), +); diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189c..e7d081c8f728 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,6 +1,20 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "Filter": + case "Encoding": + case "Pointer": + return `${issue._tag}(${summarizeSchemaIssue(issue.issue)})`; + case "Composite": + case "AnyOf": + return `${issue._tag}(${issue.issues.map(summarizeSchemaIssue).join(",")})`; + default: + return issue._tag; + } +} + // =============================== // Core Persistence Errors // =============================== @@ -9,12 +23,14 @@ export class PersistenceSqlError extends Schema.TaggedErrorClass new PersistenceSqlError({ @@ -42,22 +67,10 @@ export function toPersistenceSqlError(operation: string) { }); } +// Kept for orchestration/projection call sites, which are being revamped separately. export function toPersistenceDecodeError(operation: string) { - return (error: Schema.SchemaError): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); -} - -export function toPersistenceDecodeCauseError(operation: string) { - return (cause: unknown): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: `Failed to execute ${operation}`, - cause, - }); + return (cause: Schema.SchemaError): PersistenceDecodeError => + PersistenceDecodeError.fromSchemaError(operation, cause); } export const isPersistenceError = (u: unknown) => diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 6bbbfbd4e19d..af48efdb50ea 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -16,9 +16,9 @@ import { } from "@t3tools/contracts"; import { + PersistenceDecodeError, + PersistenceSqlError, type ProviderSessionRuntimeRepositoryError, - toPersistenceDecodeError, - toPersistenceSqlError, } from "./Errors.ts"; /** @@ -117,8 +117,8 @@ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProviderSessionRuntimeRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause) + : new PersistenceSqlError({ operation: sqlOperation, cause }); } export const make = Effect.gen(function* () { @@ -235,9 +235,10 @@ export const make = Effect.gen(function* () { onNone: () => Effect.succeed(Option.none()), onSome: (row) => decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + cause, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -259,8 +260,11 @@ export const make = Effect.gen(function* () { rows, (row) => decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:rowToRuntime", + cause, + ), ), ), { concurrency: "unbounded" }, @@ -273,7 +277,11 @@ export const make = Effect.gen(function* () { ) => deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + cause, + }), ), ); From ee3e2dae7880c2f4f0d43e04305438e083f13650 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:27:39 -0700 Subject: [PATCH 52/66] [codex] Structure remote pairing input errors (#3393) Co-authored-by: codex --- packages/shared/src/remote.test.ts | 46 ++++++++++++++- packages/shared/src/remote.ts | 93 +++++++++++++++++++++++++++--- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 5ed058b9dc52..54c789074212 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveRemotePairingTarget } from "./remote.ts"; +import { + RemoteBackendUrlInvalidError, + RemoteBackendUrlMissingError, + RemotePairingTokenMissingError, + RemotePairingUrlInvalidError, + resolveRemotePairingTarget, +} from "./remote.ts"; describe("remote", () => { it("derives backend urls and token from a pairing url", () => { @@ -65,4 +71,42 @@ describe("remote", () => { wsBaseUrl: "wss://myserver.com:3000/", }); }); + + it("uses distinct structural errors for missing pairing inputs", () => { + expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); + expect(() => + resolveRemotePairingTarget({ pairingUrl: "https://remote.example.com/pair" }), + ).toThrowError(RemotePairingTokenMissingError); + expect(() => + resolveRemotePairingTarget({ + host: "https://user:secret@remote.example.com/path?token=sensitive#fragment", + }), + ).toThrowError( + expect.objectContaining({ + _tag: "RemotePairingCodeMissingError", + host: "remote.example.com", + }), + ); + }); + + it("preserves URL parsing causes with their input source", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ pairingUrl: "not a url" }); + } catch (cause) { + pairingUrlError = cause; + } + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeInstanceOf(TypeError); + + let hostError: unknown; + try { + resolveRemotePairingTarget({ host: "https://[invalid", pairingCode: "pairing-token" }); + } catch (cause) { + hostError = cause; + } + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeInstanceOf(TypeError); + }); }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index c2d6079680de..703811609b8e 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,3 +1,5 @@ +import * as Schema from "effect/Schema"; + const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; @@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label"; const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); -const normalizeRemoteBaseUrl = (rawValue: string): URL => { +export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlMissingError", + {}, +) { + override get message(): string { + return "Enter a backend URL."; + } +} + +export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass()( + "RemotePairingUrlInvalidError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Pairing URL is invalid."; + } +} + +export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlInvalidError", + { + source: Schema.Literals(["direct-host", "hosted-pairing-host"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Backend URL is invalid."; + } +} + +export class RemotePairingTokenMissingError extends Schema.TaggedErrorClass()( + "RemotePairingTokenMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Pairing URL is missing its token."; + } +} + +export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass()( + "RemotePairingCodeMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Enter a pairing code."; + } +} + +export const RemotePairingTargetError = Schema.Union([ + RemoteBackendUrlMissingError, + RemotePairingUrlInvalidError, + RemoteBackendUrlInvalidError, + RemotePairingTokenMissingError, + RemotePairingCodeMissingError, +]); +export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; + +const normalizeRemoteBaseUrl = ( + rawValue: string, + source: RemoteBackendUrlInvalidError["source"], +): URL => { const trimmed = rawValue.trim(); if (!trimmed) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") ? trimmed : `https://${trimmed}`; - const url = new URL(normalizedInput); + let url: URL; + try { + url = new URL(normalizedInput); + } catch (cause) { + throw new RemoteBackendUrlInvalidError({ source, cause }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: { }): ResolvedRemotePairingTarget => { const pairingUrl = input.pairingUrl?.trim() ?? ""; if (pairingUrl.length > 0) { - const url = new URL(pairingUrl); + let url: URL; + try { + url = new URL(pairingUrl); + } catch (cause) { + throw new RemotePairingUrlInvalidError({ cause }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { - const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); + const hostedBackendUrl = normalizeRemoteBaseUrl( + hostedPairingRequest.host, + "hosted-pairing-host", + ); return { credential: hostedPairingRequest.token, httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), @@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: { const credential = getPairingTokenFromUrl(url) ?? ""; if (!credential) { - throw new Error("Pairing URL is missing its token."); + throw new RemotePairingTokenMissingError({ host: url.host }); } return { credential, @@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: { const host = input.host?.trim() ?? ""; const pairingCode = input.pairingCode?.trim() ?? ""; if (!host) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } + const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host"); if (!pairingCode) { - throw new Error("Enter a pairing code."); + throw new RemotePairingCodeMissingError({ host: normalizedHost.host }); } - const normalizedHost = normalizeRemoteBaseUrl(host); return { credential: pairingCode, httpBaseUrl: toHttpBaseUrl(normalizedHost), From 5b1b35c773e8ad4a85ea2f3dd277b698bf329f21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:28:06 -0700 Subject: [PATCH 53/66] [codex] Preserve desktop shell environment probe failures (#3383) Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.test.ts | 57 ++++++++++++- .../src/shell/DesktopShellEnvironment.ts | 84 ++++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 195902c3c928..7ec0ab80ae74 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,15 +1,23 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; const textEncoder = new TextEncoder(); +const isDesktopShellEnvironmentCommandError = Schema.is( + DesktopShellEnvironment.DesktopShellEnvironmentCommandError, +); + function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -59,6 +67,7 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; + readonly failure?: PlatformError.PlatformError; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, @@ -68,7 +77,11 @@ function runShellEnvironment(input: { ); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ChildProcessSpawner.make((command) => + input.failure === undefined + ? Effect.succeed(makeProcess(input.handler(command))) + : Effect.fail(input.failure), + ), ); const program = Effect.gen(function* () { @@ -229,4 +242,44 @@ describe("DesktopShellEnvironment", () => { ); }), ); + + it.effect("logs command failures with safe probe context and the exact cause", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/bash", + PATH: "/usr/bin", + }; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "/bin/bash", + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return runShellEnvironment({ + env, + platform: "linux", + handler: () => "", + failure: cause, + }).pipe( + Effect.andThen( + Effect.sync(() => { + const errors = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .filter(isDesktopShellEnvironmentCommandError); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.probe, "login-shell"); + assert.equal(errors[0]?.executable, "bash"); + assert.equal(errors[0]?.argumentCount, 2); + assert.notProperty(errors[0] ?? {}, "args"); + assert.equal(errors[0]?.cause, cause); + assert.notInclude(errors[0]?.message ?? "", cause.message); + }), + ), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 62a3b6efc91f..8219f18b7a53 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -20,6 +21,44 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } +const DesktopShellEnvironmentProbe = Schema.Literals([ + "login-shell", + "launchctl-path", + "powershell-profile", + "powershell-no-profile", +]); +type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type; + +const desktopShellEnvironmentCommandFields = { + probe: DesktopShellEnvironmentProbe, + executable: Schema.String, + argumentCount: Schema.Number, +}; + +export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandError", + { + ...desktopShellEnvironmentCommandFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`; + } +} + +export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandTimeoutError", + { + ...desktopShellEnvironmentCommandFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`; + } +} + export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { @@ -127,6 +166,18 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; +const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command; + +const logShellEnvironmentCommandError = ( + error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, +) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-shell-environment", + error, + }), + ); + const capturePosixEnvironmentCommand = (names: ReadonlyArray) => names .map((name) => { @@ -175,13 +226,14 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner + const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, @@ -193,10 +245,33 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( }), ) .pipe( + Effect.mapError( + (cause) => + new DesktopShellEnvironmentCommandError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + cause, + }), + ), + Effect.catchTags({ + DesktopShellEnvironmentCommandError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.orElseSucceed(() => ""), ); + if (Option.isSome(output)) { + return output.value; + } + + const error = new DesktopShellEnvironmentCommandTimeoutError({ + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, + timeoutMs: Duration.toMillis(input.timeout), + }); + yield* logShellEnvironmentCommandError(error); + return ""; }); const readLoginShellEnvironment = ( @@ -206,12 +281,14 @@ const readLoginShellEnvironment = ( names.length === 0 ? Effect.succeed({}) : runCommandOutput({ + probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath = runCommandOutput({ + probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, @@ -234,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ + probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, timeout: LOGIN_SHELL_TIMEOUT, From 833c8ab7c66f4e976c9ae0641d5b1d2780348734 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:28:33 -0700 Subject: [PATCH 54/66] [codex] Remove project setup error constructor wrappers (#3329) Co-authored-by: codex --- .../project/ProjectSetupScriptRunner.test.ts | 51 +++++++++++ .../src/project/ProjectSetupScriptRunner.ts | 91 ++++++++++--------- 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index d7a1bd15c58c..e8d771b74df3 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -3,11 +3,16 @@ import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; +const isProjectSetupScriptOperationError = Schema.is( + ProjectSetupScriptRunner.ProjectSetupScriptOperationError, +); + const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ id: ProjectId.make("project-1"), title: "Project", @@ -145,4 +150,50 @@ describe("ProjectSetupScriptRunner", () => { }).pipe(Effect.provide(testLayer(project, { open, write }))); }, ); + + it.effect("keeps terminal failures as the exact cause of a structured operation error", () => { + const rootCause = new Error("stat failed"); + const terminalError = new TerminalManager.TerminalCwdError({ + cwd: "/repo/worktrees/a", + reason: "statFailed", + cause: rootCause, + }); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const error = yield* runner + .runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isProjectSetupScriptOperationError(error)).toBe(true); + if (isProjectSetupScriptOperationError(error)) { + expect(error.operation).toBe("openTerminal"); + expect(error.threadId).toBe("thread-1"); + expect(error.projectId).toBe("project-1"); + expect(error.worktreePath).toBe("/repo/worktrees/a"); + expect(error.cause).toBe(terminalError); + expect(terminalError.cause).toBe(rootCause); + } + }).pipe( + Effect.provide( + testLayer(project, { + open: () => Effect.fail(terminalError), + write: () => Effect.die("unexpected write"), + }), + ), + ); + }); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index dc97da51f245..41bf0fabf489 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -59,7 +59,7 @@ export class ProjectSetupScriptProjectNotFoundError extends Schema.TaggedErrorCl }, ) { override get message(): string { - return "Project was not found for setup script execution."; + return `Project was not found for setup script execution for thread '${this.threadId}' in '${this.worktreePath}'.`; } } @@ -78,32 +78,6 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} -const isProjectSetupScriptRunnerError = Schema.is(ProjectSetupScriptRunnerError); - -function operationError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], - cause: unknown, -): ProjectSetupScriptOperationError { - return new ProjectSetupScriptOperationError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - operation, - cause, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); -} - -function mapRunnerError( - input: ProjectSetupScriptRunnerInput, - operation: ProjectSetupScriptOperationError["operation"], -) { - return Effect.mapError((cause: unknown) => - isProjectSetupScriptRunnerError(cause) ? cause : operationError(input, operation, cause), - ); -} - export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; @@ -111,26 +85,43 @@ export const make = Effect.gen(function* () { const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( "ProjectSetupScriptRunner.runForThread", )(function* (input) { + const errorContext = { + threadId: input.threadId, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; const projectById = input.projectId - ? yield* projectionSnapshotQuery - .getProjectShellById(ProjectId.make(input.projectId)) - .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + ? yield* projectionSnapshotQuery.getProjectShellById(ProjectId.make(input.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) : null; const project = projectById ?? (input.projectCwd - ? yield* projectionSnapshotQuery - .getActiveProjectByWorkspaceRoot(input.projectCwd) - .pipe(Effect.map(Option.getOrUndefined), mapRunnerError(input, "resolveProject")) + ? yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(input.projectCwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }), + ), + ) : null); if (!project) { - return yield* new ProjectSetupScriptProjectNotFoundError({ - threadId: input.threadId, - worktreePath: input.worktreePath, - ...(input.projectId === undefined ? {} : { projectId: input.projectId }), - ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), - }); + return yield* new ProjectSetupScriptProjectNotFoundError(errorContext); } const script = setupProjectScript(project.scripts); @@ -155,14 +146,32 @@ export const make = Effect.gen(function* () { worktreePath: input.worktreePath, env, }) - .pipe(mapRunnerError(input, "openTerminal")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "openTerminal", + cause, + }), + ), + ); yield* terminalManager .write({ threadId: input.threadId, terminalId, data: `${script.command}\r`, }) - .pipe(mapRunnerError(input, "writeCommand")); + .pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "writeCommand", + cause, + }), + ), + ); return { status: "started", From 1dc36cef73962441befca71678d09fbefdd08c9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:29:28 -0700 Subject: [PATCH 55/66] Structure relay auth parsing errors (#3290) Co-authored-by: codex --- packages/shared/src/relayAuth.test.ts | 62 ++++++++++++++++++++ packages/shared/src/relayAuth.ts | 82 +++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index dc06ce5323c2..3abff9b52109 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -1,17 +1,79 @@ import { describe, expect, it } from "vite-plus/test"; import { + ClerkPublishableKeyDecodeError, + ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, + clerkFrontendApiUrlFromPublishableKey, isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; +const captureError = (run: () => unknown): unknown => { + try { + run(); + } catch (cause) { + return cause; + } + throw new Error("Expected operation to throw"); +}; + describe("Clerk relay auth", () => { it("derives a custom Frontend API hostname from a Clerk publishable key", () => { expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( "clerk.t3.codes", ); + expect(clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( + "https://clerk.t3.codes", + ); + }); + + it("preserves Clerk publishable key decoding failures", () => { + const error = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_%")); + + expect(error).toBeInstanceOf(ClerkPublishableKeyDecodeError); + expect(error).toMatchObject({ keyPrefix: "pk_test" }); + expect((error as ClerkPublishableKeyDecodeError).cause).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Failed to decode Clerk publishable key (pk_test)."); + }); + + it("reports semantic frontend API failures without inventing a cause", () => { + const emptyError = captureError(() => clerkFrontendApiUrlFromPublishableKey("pk_test_")); + const pathFrontendApi = "clerk.t3.codes/path"; + const pathError = captureError(() => + clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey(pathFrontendApi)), + ); + + expect(emptyError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(emptyError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: "", + reason: "empty", + }); + expect((emptyError as Error & { cause?: unknown }).cause).toBeUndefined(); + expect(pathError).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(pathError).toMatchObject({ + keyPrefix: "pk_test", + frontendApi: pathFrontendApi, + reason: "contains-path", + }); + expect((pathError as Error & { cause?: unknown }).cause).toBeUndefined(); + }); + + it("preserves URL parser failures for decoded frontend APIs", () => { + const frontendApi = "[invalid-host"; + const error = captureError(() => + clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey(frontendApi)), + ); + + expect(error).toBeInstanceOf(ClerkPublishableKeyFrontendApiError); + expect(error).toMatchObject({ + keyPrefix: "pk_test", + frontendApi, + reason: "invalid-url", + }); + expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); it("allows standard Clerk hosts and an exact configured custom hostname", () => { diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index bf5fb61ee3b3..a384db77d8ac 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,14 +1,84 @@ -export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { +import * as Schema from "effect/Schema"; + +const ClerkPublishableKeyPrefix = Schema.Literals(["pk_test", "pk_live", "unknown"]); + +export class ClerkPublishableKeyDecodeError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyDecodeError", + { + keyPrefix: ClerkPublishableKeyPrefix, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode Clerk publishable key (${this.keyPrefix}).`; + } +} + +export class ClerkPublishableKeyFrontendApiError extends Schema.TaggedErrorClass()( + "ClerkPublishableKeyFrontendApiError", + { + keyPrefix: ClerkPublishableKeyPrefix, + frontendApi: Schema.String, + reason: Schema.Literals(["empty", "contains-path", "invalid-url"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid Clerk frontend API decoded from publishable key (${this.keyPrefix}; ${this.reason}).`; + } +} + +function parseClerkFrontendApi(publishableKey: string): { + readonly hostname: string; + readonly url: string; +} { + const keyPrefix = publishableKey.startsWith("pk_test_") + ? "pk_test" + : publishableKey.startsWith("pk_live_") + ? "pk_live" + : "unknown"; const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); - const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); - if (frontendApi.length === 0 || frontendApi.includes("/")) { - throw new Error("Invalid Clerk publishable key."); + let frontendApi: string; + try { + frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + } catch (cause) { + throw new ClerkPublishableKeyDecodeError({ keyPrefix, cause }); } - return `https://${frontendApi}`; + + if (frontendApi.length === 0) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "empty", + }); + } + if (frontendApi.includes("/")) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "contains-path", + }); + } + + const url = `https://${frontendApi}`; + try { + return { hostname: new URL(url).hostname, url }; + } catch (cause) { + throw new ClerkPublishableKeyFrontendApiError({ + keyPrefix, + frontendApi, + reason: "invalid-url", + cause, + }); + } +} + +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { + return parseClerkFrontendApi(publishableKey).url; } export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { - return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; + return parseClerkFrontendApi(publishableKey).hostname; } export function isAllowedClerkFrontendApiHostname( From e01b1903de403c720792a896d8c50ebcd8163b44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:30:17 -0700 Subject: [PATCH 56/66] [codex] Structure process diagnostics failures (#3389) Co-authored-by: codex --- .../diagnostics/ProcessDiagnostics.test.ts | 39 ++++++ .../src/diagnostics/ProcessDiagnostics.ts | 111 ++++++++++++------ 2 files changed, 117 insertions(+), 33 deletions(-) diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 18a54326de17..7d16a11c829c 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; @@ -219,6 +220,44 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("keeps bounded command diagnostics when the process query exits unsuccessfully", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + code: 17, + stdout: "partial process output", + stderr: "process access denied", + }), + ), + ), + ); + + const error = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ProcessDiagnosticsQueryFailedError", + command: "ps", + argCount: 2, + cwd: process.cwd(), + exitCode: 17, + stdoutBytes: 22, + stderrBytes: 21, + stdoutTruncated: false, + stderrTruncated: false, + }); + expect(error.message).toBe( + `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, + ); + }), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 40e7f347be1a..b39d560a2280 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -45,10 +45,15 @@ export class ProcessDiagnostics extends Context.Service< class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsQueryTimeoutError", - { command: Schema.String }, + { + command: Schema.String, + argCount: Schema.Number, + cwd: Schema.String, + timeoutMillis: Schema.Number, + }, ) { override get message(): string { - return `Process diagnostics query '${this.command}' timed out.`; + return `Process diagnostics query '${this.command}' timed out after ${this.timeoutMillis}ms in '${this.cwd}'.`; } } @@ -56,12 +61,19 @@ class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass()( "ProcessDiagnosticsNotDescendantError", - { pid: Schema.Number }, + { + pid: Schema.Number, + serverPid: Schema.Number, + }, ) { override get message(): string { return `Process ${this.pid} is not a live descendant of the T3 server.`; @@ -312,20 +327,29 @@ function makeResult(input: { } interface ProcessOutput { + readonly cwd: string; readonly exitCode: number; readonly stdout: string; + readonly stdoutBytes: number; + readonly stdoutTruncated: boolean; readonly stderr: string; + readonly stderrBytes: number; + readonly stderrTruncated: boolean; } -const runProcess = Effect.fn("runProcess")( - function* (input: { readonly command: string; readonly args: ReadonlyArray }) { +const runProcess = Effect.fn("runProcess")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const cwd = process.cwd(); + return yield* Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; // `ps` and `powershell.exe` are real executables; spawning through cmd.exe // shell mode would re-tokenize the PowerShell `-Command` payload (which // contains pipes) before PowerShell ever sees it. const child = yield* spawner.spawn( ChildProcess.make(input.command, input.args, { - cwd: process.cwd(), + cwd, }), ); const [stdout, stderr, exitCode] = yield* Effect.all( @@ -346,36 +370,44 @@ const runProcess = Effect.fn("runProcess")( ); return { + cwd, exitCode, stdout: stdout.text, + stdoutBytes: stdout.bytes, + stdoutTruncated: stdout.truncated, stderr: stderr.text, + stderrBytes: stderr.bytes, + stderrTruncated: stderr.truncated, } satisfies ProcessOutput; - }, - (effect, input) => - effect.pipe( - Effect.scoped, - Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new ProcessDiagnosticsQueryTimeoutError({ - command: input.command, - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isProcessDiagnosticsError(cause) - ? cause - : new ProcessDiagnosticsQueryFailedError({ + }).pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new ProcessDiagnosticsQueryTimeoutError({ command: input.command, - cause, + argCount: input.args.length, + cwd, + timeoutMillis: PROCESS_QUERY_TIMEOUT_MS, }), - ), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isProcessDiagnosticsError(cause) + ? cause + : new ProcessDiagnosticsQueryFailedError({ + command: input.command, + argCount: input.args.length, + cwd, + cause, + }), ), -); + ); +}); function readPosixProcessRows(): Effect.Effect< ReadonlyArray, @@ -391,7 +423,13 @@ function readPosixProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "ps", - stderr: result.stderr.trim() || "ps failed.", + argCount: 2, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, }), ) : Effect.succeed(parsePosixProcessRows(result.stdout)), @@ -421,7 +459,13 @@ function readWindowsProcessRows(): Effect.Effect< ? Effect.fail( new ProcessDiagnosticsQueryFailedError({ command: "powershell.exe", - stderr: result.stderr.trim() || "PowerShell process query failed.", + argCount: 4, + cwd: result.cwd, + exitCode: result.exitCode, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, }), ) : Effect.succeed(parseWindowsProcessRows(result.stdout)), @@ -464,6 +508,7 @@ function assertDescendantPid( : Effect.fail( new ProcessDiagnosticsNotDescendantError({ pid, + serverPid: process.pid, }), ); }), From 7eb7b4f4a36aea3b858494e5983ee9bc9a288933 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:31:09 -0700 Subject: [PATCH 57/66] [codex] Structure release metadata failures (#3296) Co-authored-by: codex --- scripts/resolve-nightly-release.test.ts | 23 +++++-- scripts/resolve-nightly-release.ts | 17 ++++- scripts/resolve-previous-release-tag.test.ts | 39 ++++++++++++ scripts/resolve-previous-release-tag.ts | 65 ++++++++++++-------- 4 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 scripts/resolve-previous-release-tag.test.ts diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 82b25737a58d..ecc94c57f599 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { resolveNightlyBaseVersion, @@ -12,11 +13,23 @@ it("strips prerelease and build metadata when deriving the nightly base version" assert.equal(resolveNightlyBaseVersion("1.2.3-beta.4+build.9"), "1.2.3"); }); -it("bumps the patch version before deriving nightly prerelease versions", () => { - assert.equal(resolveNightlyTargetVersion("0.0.17"), "0.0.18"); - assert.equal(resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); - assert.equal(resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); -}); +it.effect("bumps the patch version before deriving nightly prerelease versions", () => + Effect.gen(function* () { + assert.equal(yield* resolveNightlyTargetVersion("0.0.17"), "0.0.18"); + assert.equal(yield* resolveNightlyTargetVersion("9.9.9-smoke.0"), "9.9.10"); + assert.equal(yield* resolveNightlyTargetVersion("1.2.3-beta.4+build.9"), "1.2.4"); + }), +); + +it.effect("reports the invalid desktop package version", () => + Effect.gen(function* () { + const error = yield* resolveNightlyTargetVersion("nightly").pipe(Effect.flip); + + assert.equal(error._tag, "InvalidDesktopPackageVersionError"); + assert.equal(error.version, "nightly"); + assert.equal(error.message, "Invalid desktop package version 'nightly'."); + }), +); it("derives nightly metadata including the short commit sha in the release name", () => { assert.deepStrictEqual( diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index e3f064305bf2..ae6bc323c67b 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -29,6 +29,17 @@ const DesktopPackageJsonSchema = Schema.Struct({ version: Schema.NonEmptyString, }); +export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass()( + "InvalidDesktopPackageVersionError", + { + version: Schema.String, + }, +) { + override get message(): string { + return `Invalid desktop package version '${this.version}'.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -42,11 +53,11 @@ export const resolveNightlyTargetVersion = (version: string) => { const stableCore = resolveNightlyBaseVersion(version); const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stableCore); if (!match) { - throw new Error(`Invalid desktop package version '${version}'.`); + return Effect.fail(new InvalidDesktopPackageVersionError({ version })); } const [, major, minor, patch] = match; - return `${major}.${minor}.${Number(patch) + 1}`; + return Effect.succeed(`${major}.${minor}.${Number(patch) + 1}`); }; export const resolveNightlyReleaseMetadata = ( @@ -76,7 +87,7 @@ const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( const packageJson = yield* fs .readFileString(packageJsonPath) .pipe(Effect.flatMap(decodeDesktopPackageJson)); - return resolveNightlyTargetVersion(packageJson.version); + return yield* resolveNightlyTargetVersion(packageJson.version); }); const writeOutput = Effect.fn("writeOutput")(function* ( diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts new file mode 100644 index 000000000000..ecf564c005e0 --- /dev/null +++ b/scripts/resolve-previous-release-tag.test.ts @@ -0,0 +1,39 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; + +it.effect("selects the latest earlier stable tag and ignores nightlies", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [ + "v1.1.0", + "v1.1.1-nightly.20260619.1", + "v1.1.2", + "v1.2.0", + ]); + + assert.equal(previous, "v1.1.2"); + }), +); + +it.effect("accepts legacy nightly tags when selecting the previous nightly", () => + Effect.gen(function* () { + const previous = yield* resolvePreviousReleaseTag("nightly", "v1.2.0-nightly.20260620.2", [ + "nightly-v1.2.0-nightly.20260620.1", + "v1.1.0-nightly.20260619.9", + ]); + + assert.equal(previous, "nightly-v1.2.0-nightly.20260620.1"); + }), +); + +it.effect("reports the invalid tag with its release channel", () => + Effect.gen(function* () { + const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip); + + assert.equal(error._tag, "InvalidReleaseTagError"); + assert.equal(error.channel, "nightly"); + assert.equal(error.currentTag, "v1.2.0"); + assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); + }), +); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index f75c3a4f8a4b..8b1f1fc96480 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -15,6 +15,18 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const ReleaseChannel = Schema.Literals(["stable", "nightly"]); type ReleaseChannel = typeof ReleaseChannel.Type; +export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "InvalidReleaseTagError", + { + channel: ReleaseChannel, + currentTag: Schema.String, + }, +) { + override get message(): string { + return `Invalid ${this.channel} release tag '${this.currentTag}'.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -121,41 +133,44 @@ const parseNightlyTag = (tag: string): NightlyVersion | undefined => { }; }; -const resolvePreviousReleaseTag = ( +export const resolvePreviousReleaseTag = ( channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray, -): string | undefined => { - if (channel === "stable") { - const current = parseStableTag(currentTag); +) => + Effect.gen(function* () { + if (channel === "stable") { + const current = parseStableTag(currentTag); + if (!current) { + return yield* new InvalidReleaseTagError({ channel, currentTag }); + } + + const candidates = tags + .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .filter( + (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + ) + .filter((entry) => compareStableVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + + return candidates[0]?.tag; + } + + const current = parseNightlyTag(currentTag); if (!current) { - throw new Error(`Invalid stable release tag '${currentTag}'.`); + return yield* new InvalidReleaseTagError({ channel, currentTag }); } const candidates = tags - .map((tag) => ({ tag, parsed: parseStableTag(tag) })) + .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) .filter( - (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined, + (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined, ) - .filter((entry) => compareStableVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed)); + .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) + .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); return candidates[0]?.tag; - } - - const current = parseNightlyTag(currentTag); - if (!current) { - throw new Error(`Invalid nightly release tag '${currentTag}'.`); - } - - const candidates = tags - .map((tag) => ({ tag, parsed: parseNightlyTag(tag) })) - .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined) - .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0) - .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed)); - - return candidates[0]?.tag; -}; + }); const listGitTags = Effect.fn("listGitTags")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -205,7 +220,7 @@ const command = Command.make( }, ({ channel, currentTag, githubOutput }) => listGitTags().pipe( - Effect.map((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), + Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); From 708bc7091dcdf5073a374464ffcbe45ba520b8fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:31:57 -0700 Subject: [PATCH 58/66] Structure server environment ID failures (#3286) Co-authored-by: codex --- .../src/environment/ServerEnvironment.test.ts | 103 +++++++++--------- .../src/environment/ServerEnvironment.ts | 52 +++++++-- 2 files changed, 98 insertions(+), 57 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 665447589eb4..6b3290246fea 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,16 +1,18 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; +const isServerEnvironmentIdPersistenceError = Schema.is( + ServerEnvironment.ServerEnvironmentIdPersistenceError, +); + const makeServerEnvironmentLayer = (baseDir: string) => ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); @@ -68,62 +70,63 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); - it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-environment-read-error-test-", + prefix: "t3-server-environment-error-test-", }); const serverConfig = yield* makeServerConfig(baseDir); const environmentIdPath = serverConfig.environmentIdPath; - yield* fileSystem.makeDirectory(NodePath.dirname(environmentIdPath), { recursive: true }); - yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); - const writeAttempts: string[] = []; - const failingFileSystemLayer = FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === environmentIdPath), - readFileString: (path) => - path === environmentIdPath - ? Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "readFileString", - description: "permission denied", - pathOrDescriptor: path, - }), - ) - : Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFileString", - description: "not found", - pathOrDescriptor: path, - }), - ), - writeFileString: (path) => { - writeAttempts.push(path); - return Effect.void; - }, - }); + const methodByOperation = { + check: "exists", + read: "readFileString", + write: "writeFileString", + } as const; - const exit = yield* Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - return yield* serverEnvironment.getDescriptor; - }).pipe( - Effect.provide( - ServerEnvironment.layer.pipe( - Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), + for (const operation of ["check", "read", "write"] as const) { + const writeAttempts: string[] = []; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: methodByOperation[operation], + description: "permission denied", + pathOrDescriptor: environmentIdPath, + }); + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: () => + operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), + readFileString: () => Effect.fail(cause), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.fail(cause); + }, + }); + + const error = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), + ), ), - ), - Effect.exit, - ); + Effect.flip, + ); - expect(Exit.isFailure(exit)).toBe(true); - expect(writeAttempts).toEqual([]); - expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( - "persisted-environment-id\n", - ); + expect(isServerEnvironmentIdPersistenceError(error)).toBe(true); + if (!isServerEnvironmentIdPersistenceError(error)) { + throw error; + } + expect(error.operation).toBe(operation); + expect(error.environmentIdPath).toBe(environmentIdPath); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + `Server environment ID ${operation} failed at '${environmentIdPath}'.`, + ); + expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); + } }), ); }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 433a9d3f02ae..b5fbd8e1088c 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -6,12 +6,26 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; +export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( + "ServerEnvironmentIdPersistenceError", + { + operation: Schema.Literals(["check", "read", "write"]), + environmentIdPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; + } +} + export class ServerEnvironment extends Context.Service< ServerEnvironment, { @@ -55,22 +69,46 @@ export const make = Effect.gen(function* () { const hostArchitecture = yield* HostProcessArchitecture; const readPersistedEnvironmentId = Effect.gen(function* () { - const exists = yield* fileSystem - .exists(serverConfig.environmentIdPath) - .pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "check", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); if (!exists) { return null; } - const raw = yield* fileSystem - .readFileString(serverConfig.environmentIdPath) - .pipe(Effect.map((value) => value.trim())); + const raw = yield* fileSystem.readFileString(serverConfig.environmentIdPath).pipe( + Effect.map((value) => value.trim()), + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "read", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); return raw.length > 0 ? raw : null; }); const persistEnvironmentId = (value: string) => - fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( + Effect.mapError( + (cause) => + new ServerEnvironmentIdPersistenceError({ + operation: "write", + environmentIdPath: serverConfig.environmentIdPath, + cause, + }), + ), + ); const environmentIdRaw = yield* Effect.gen(function* () { const persisted = yield* readPersistedEnvironmentId; From 2c16edd0cb1169ce916e3da6a736d371954532a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:32:47 -0700 Subject: [PATCH 59/66] [codex] Structure desktop bridge state errors (#3381) Co-authored-by: codex --- .../src/state/desktopNetworkAccess.test.ts | 37 ++++++++++ apps/web/src/state/desktopNetworkAccess.ts | 67 ++++++++++++------- apps/web/src/state/desktopSshHosts.test.ts | 22 ++++++ apps/web/src/state/desktopSshHosts.ts | 30 +++++---- 4 files changed, 119 insertions(+), 37 deletions(-) diff --git a/apps/web/src/state/desktopNetworkAccess.test.ts b/apps/web/src/state/desktopNetworkAccess.test.ts index 7af13cbbcfce..0dde5f7d7dc8 100644 --- a/apps/web/src/state/desktopNetworkAccess.test.ts +++ b/apps/web/src/state/desktopNetworkAccess.test.ts @@ -1,4 +1,5 @@ import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = { }; const advertisedEndpoints: ReadonlyArray = []; +const serverExposureLoadCause = new Error("exposure failed"); +const advertisedEndpointsLoadCause = new Error("endpoints failed"); describe("desktopNetworkAccessState", () => { it("retains the loaded snapshot when the settings screen remounts", async () => { @@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => { remount(); registry.dispose(); }); + + it.each([ + { + cause: serverExposureLoadCause, + expectedTag: "DesktopServerExposureStateLoadError", + getAdvertisedEndpoints: async () => advertisedEndpoints, + getServerExposureState: async () => Promise.reject(serverExposureLoadCause), + }, + { + cause: advertisedEndpointsLoadCause, + expectedTag: "DesktopAdvertisedEndpointsLoadError", + getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause), + getServerExposureState: async () => serverExposureState, + }, + ])("retains the $expectedTag cause", async (testCase) => { + const atom = createDesktopNetworkAccessStateAtom(() => ({ + getAdvertisedEndpoints: testCase.getAdvertisedEndpoints, + getServerExposureState: testCase.getServerExposureState, + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: testCase.expectedTag, + cause: testCase.cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopNetworkAccess.ts b/apps/web/src/state/desktopNetworkAccess.ts index 150a256bc688..07580fcd1645 100644 --- a/apps/web/src/state/desktopNetworkAccess.ts +++ b/apps/web/src/state/desktopNetworkAccess.ts @@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot { readonly serverExposureState: DesktopServerExposureState; } -class DesktopNetworkAccessError extends Schema.TaggedErrorClass()( - "DesktopNetworkAccessError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} +class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass()( + "DesktopNetworkAccessUnavailableError", + {}, +) { + override get message(): string { + return "Desktop network access is unavailable."; + } +} + +class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass()( + "DesktopServerExposureStateLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load desktop server exposure state."; + } +} + +class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass()( + "DesktopAdvertisedEndpointsLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load advertised desktop endpoints."; + } +} function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom( const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopNetworkAccessError({ - message: "Desktop network access is unavailable.", - }); + return yield* new DesktopNetworkAccessUnavailableError(); } - return yield* Effect.tryPromise({ - try: async (): Promise => { - const [serverExposureState, advertisedEndpoints] = await Promise.all([ - bridge.getServerExposureState(), - bridge.getAdvertisedEndpoints(), - ]); - return { advertisedEndpoints, serverExposureState }; - }, - catch: (cause) => - new DesktopNetworkAccessError({ - message: - cause instanceof Error ? cause.message : "Failed to load desktop network access.", - cause, + const [serverExposureState, advertisedEndpoints] = yield* Effect.all( + [ + Effect.tryPromise({ + try: () => bridge.getServerExposureState(), + catch: (cause) => new DesktopServerExposureStateLoadError({ cause }), + }), + Effect.tryPromise({ + try: () => bridge.getAdvertisedEndpoints(), + catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }), }), - }); + ], + { concurrency: "unbounded" }, + ); + return { + advertisedEndpoints, + serverExposureState, + } satisfies DesktopNetworkAccessSnapshot; }); return Atom.make(loadDesktopNetworkAccess()).pipe( diff --git a/apps/web/src/state/desktopSshHosts.test.ts b/apps/web/src/state/desktopSshHosts.test.ts index 571704a95f57..83eda60158c2 100644 --- a/apps/web/src/state/desktopSshHosts.test.ts +++ b/apps/web/src/state/desktopSshHosts.test.ts @@ -1,4 +1,5 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AtomRegistry } from "effect/unstable/reactivity"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => { remount(); registry.dispose(); }); + + it("retains the desktop bridge failure as the discovery error cause", async () => { + const cause = new Error("ssh config unavailable"); + const atom = createDesktopSshHostsStateAtom(() => ({ + discoverSshHosts: async () => Promise.reject(cause), + })); + const registry = AtomRegistry.make(); + registry.mount(atom); + + await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true)); + const result = registry.get(atom); + if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail."); + + expect(Cause.squash(result.cause)).toEqual( + expect.objectContaining({ + _tag: "DesktopSshDiscoveryError", + cause, + }), + ); + registry.dispose(); + }); }); diff --git a/apps/web/src/state/desktopSshHosts.ts b/apps/web/src/state/desktopSshHosts.ts index 47b2c87e97c5..8e4022cbecf8 100644 --- a/apps/web/src/state/desktopSshHosts.ts +++ b/apps/web/src/state/desktopSshHosts.ts @@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity"; type DesktopSshDiscoveryBridge = Pick; +class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass()( + "DesktopSshDiscoveryUnavailableError", + {}, +) { + override get message(): string { + return "Desktop SSH host discovery is unavailable."; + } +} + class DesktopSshDiscoveryError extends Schema.TaggedErrorClass()( "DesktopSshDiscoveryError", - { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to discover SSH hosts."; + } +} function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined { return typeof window === "undefined" ? undefined : window.desktopBridge; @@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom( const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () { const bridge = getBridge(); if (!bridge) { - return yield* new DesktopSshDiscoveryError({ - message: "Desktop SSH host discovery is unavailable.", - }); + return yield* new DesktopSshDiscoveryUnavailableError(); } return yield* Effect.tryPromise({ try: (): Promise> => bridge.discoverSshHosts(), - catch: (cause) => - new DesktopSshDiscoveryError({ - message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.", - cause, - }), + catch: (cause) => new DesktopSshDiscoveryError({ cause }), }); }); From 32c7f90d9e6d4045a709b2a0faf30b977bdf26da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:36:08 -0700 Subject: [PATCH 60/66] [codex] Structure APNs delivery queue errors (#3326) Co-authored-by: codex --- .../agentActivity/ApnsDeliveryQueue.test.ts | 79 +++++++++++++++++++ .../src/agentActivity/ApnsDeliveryQueue.ts | 77 +++++++++++++++--- 2 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts new file mode 100644 index 000000000000..b3a8083efe83 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.test.ts @@ -0,0 +1,79 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; + +const config: RelayConfiguration.RelayConfiguration["Service"] = { + relayIssuer: "https://relay.example.com", + apns: { + teamId: "team-1", + keyId: "key-1", + privateKey: Redacted.make("apns-private-key"), + bundleId: "com.t3tools.test", + environment: "sandbox", + }, + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}; + +describe("ApnsDeliveryQueue", () => { + it.effect("preserves job identity and the queue sender cause", () => { + const cause = new Error("queue unavailable"); + const senderCause = new Cloudflare.QueueSendError({ + message: cause.message, + cause, + }); + const layer = ApnsDeliveryQueue.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(RelayConfiguration.layer(config)), + Layer.provide( + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: () => Effect.fail(senderCause), + }), + ), + ); + + return Effect.gen(function* () { + const queue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const error = yield* Effect.flip( + queue.enqueuePushNotification({ + userId: "user-1", + deviceId: "device-1", + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env-1", + threadId: "thread-1", + deepLink: "/threads/env-1/thread-1", + }, + }), + ); + + expect(error).toMatchObject({ + _tag: "ApnsDeliveryQueueSendError", + operation: "send", + jobId: expect.any(String), + kind: "push_notification", + userId: "user-1", + deviceId: "device-1", + cause: senderCause, + }); + expect(senderCause.cause).toBe(cause); + expect(error.message).toBe( + "Failed to enqueue APNs push notification delivery during send for device device-1.", + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 980eab169533..6c1fd79dc1ce 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -7,7 +7,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; +import { + RelayDeliveryKind as RelayDeliveryKindSchema, + type RelayDeliveryResult, +} from "@t3tools/contracts/relay"; import { sanitizeAgentActivityAggregateState, @@ -24,10 +27,17 @@ import * as RelayConfiguration from "../Config.ts"; export class ApnsDeliveryQueueSendError extends Schema.TaggedErrorClass()( "ApnsDeliveryQueueSendError", - { cause: Schema.Defect() }, + { + operation: Schema.Literals(["generate-job-id", "send"]), + jobId: Schema.NullOr(Schema.String), + kind: RelayDeliveryKindSchema, + userId: Schema.String, + deviceId: Schema.String, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Failed to enqueue APNs delivery"; + return `Failed to enqueue APNs ${this.kind.replaceAll("_", " ")} delivery during ${this.operation} for device ${this.deviceId}.`; } } @@ -36,7 +46,7 @@ export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; export class ApnsDeliveryQueueSender extends Context.Service< ApnsDeliveryQueueSender, { - readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; + readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; } >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} @@ -73,7 +83,17 @@ export const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -88,7 +108,19 @@ export const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: input.kind, + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: input.kind, @@ -110,7 +142,17 @@ export const make = Effect.gen(function* () { }); const now = yield* DateTime.now; const jobId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "generate-job-id", + jobId: null, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), ); yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); const payload = makeApnsDeliveryJobPayload({ @@ -128,7 +170,19 @@ export const make = Effect.gen(function* () { secret: config.apnsDeliveryJobSigningSecret, payload, }); - yield* sender.send(signed); + yield* sender.send(signed).pipe( + Effect.mapError( + (cause) => + new ApnsDeliveryQueueSendError({ + operation: "send", + jobId, + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + cause, + }), + ), + ); return { deviceId: input.deviceId, kind: "push_notification" as const, @@ -155,10 +209,9 @@ export const layerCloudflareQueues = ( ApnsDeliveryQueueSender, ApnsDeliveryQueueSender.of({ send: (body) => - sender.send(body).pipe( - Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), + sender + .send(body) + .pipe(Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext)), }), ), ), From d512deac84aecb8ec2e4978599ce31a3185f5350 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:37:01 -0700 Subject: [PATCH 61/66] [codex] Structure preview URL failures (#3275) Co-authored-by: codex --- apps/server/src/preview/Manager.test.ts | 26 ++++++++++++++ apps/server/src/preview/Manager.ts | 28 +++++++++------ packages/contracts/src/preview.ts | 11 +++--- packages/shared/src/preview.test.ts | 46 ++++++++++++++++++++++--- packages/shared/src/preview.ts | 46 +++++++++++++++++-------- 5 files changed, 122 insertions(+), 35 deletions(-) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index a910e27470d2..acdfe54301eb 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -1,5 +1,6 @@ import { it } from "@effect/vitest"; import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; +import { PreviewUrlNormalizationError } from "@t3tools/shared/preview"; import { Effect, PubSub } from "effect"; import { expect } from "vite-plus/test"; @@ -83,6 +84,31 @@ it.layer(PreviewManager.layer)("PreviewManager", (it) => { const manager = yield* PreviewManager.PreviewManager; const error = yield* Effect.flip(manager.open({ threadId, url: " " })); expect(error._tag).toBe("PreviewInvalidUrlError"); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + expect((error.cause as PreviewUrlNormalizationError).reason).toBe("empty"); + }), + ); + + it.effect("preserves URL parser failures as the invalid URL cause chain", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + const error = yield* Effect.flip(manager.open({ threadId, url: rawUrl })); + + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect(error.cause).toBeInstanceOf(PreviewUrlNormalizationError); + const normalizationError = error.cause as PreviewUrlNormalizationError; + expect(normalizationError.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((normalizationError.cause as Error).message); + expect(error.message).not.toMatch(/user|password|access_token|secret|fragment/); }), ); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 159932c4bdc8..fe3557c157f9 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -24,9 +24,9 @@ import { type PreviewSessionSnapshot, } from "@t3tools/contracts"; import { + isPreviewUrlNormalizationError, newPreviewTabId, normalizePreviewUrl, - PreviewUrlNormalizationError, } from "@t3tools/shared/preview"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -82,16 +82,22 @@ const sessionsForThread = ( const normalizeUrl = (rawUrl: string): Effect.Effect => Effect.try({ try: () => normalizePreviewUrl(rawUrl), - catch: (cause) => - new PreviewInvalidUrlError({ - rawUrl, - detail: - cause instanceof PreviewUrlNormalizationError - ? cause.detail - : cause instanceof Error - ? cause.message - : String(cause), - }), + catch: (cause) => { + if (isPreviewUrlNormalizationError(cause)) { + return new PreviewInvalidUrlError({ + inputLength: cause.inputLength, + reason: cause.reason, + protocol: cause.protocol, + cause, + }); + } + + return new PreviewInvalidUrlError({ + inputLength: rawUrl.length, + reason: "unexpected", + cause, + }); + }, }); const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 044b8fbbd07d..457e66ee07f8 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -172,14 +172,15 @@ export class PreviewSessionLookupError extends Schema.TaggedErrorClass()( "PreviewInvalidUrlError", { - rawUrl: Schema.String, - detail: Schema.optional(Schema.String), + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]), + protocol: Schema.optional(Schema.String), + cause: Schema.Defect(), }, ) { override get message() { - return this.detail - ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` - : `Invalid preview URL: ${this.rawUrl}`; + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index 6030686d3ed9..fec4203c5334 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -61,15 +61,51 @@ describe("normalizePreviewUrl", () => { }); it("rejects empty input", () => { - expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl(" "); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ inputLength: 3, reason: "empty" }); + expect(error).not.toHaveProperty("rawUrl"); + expect("cause" in (error as object)).toBe(false); + } }); it("rejects unsupported protocols", () => { - expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); - expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + try { + normalizePreviewUrl("ftp://example.com"); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: "ftp://example.com".length, + reason: "unsupported-protocol", + protocol: "ftp:", + }); + } }); - it("rejects unparseable junk", () => { - expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + it("rejects unparseable input without retaining credentials or tokens", () => { + const rawUrl = "https://user:password@example.com:bad/path?access_token=secret#fragment"; + try { + normalizePreviewUrl(rawUrl); + expect.unreachable("expected URL normalization to fail"); + } catch (error) { + expect(error).toBeInstanceOf(PreviewUrlNormalizationError); + expect(error).toMatchObject({ + inputLength: rawUrl.length, + reason: "parse", + protocol: "https:", + }); + expect(error).not.toHaveProperty("rawUrl"); + expect((error as PreviewUrlNormalizationError).cause).toBeInstanceOf(Error); + expect((error as PreviewUrlNormalizationError).message).not.toContain( + ((error as PreviewUrlNormalizationError).cause as Error).message, + ); + expect((error as PreviewUrlNormalizationError).message).not.toMatch( + /user|password|access_token|secret|fragment/, + ); + } }); }); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index cc5a765ddcb7..926b30966e52 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -4,6 +4,8 @@ * on what counts as "loopback" and how to normalise a free-form URL string. */ +import * as Schema from "effect/Schema"; + const TAB_ID_PREFIX = "tab_"; let nextPreviewTabSequence = 0; @@ -45,17 +47,27 @@ export function isPreviewableUrl(rawUrl: string): boolean { } } -export class PreviewUrlNormalizationError extends Error { - readonly rawUrl: string; - readonly detail: string; - constructor(rawUrl: string, detail: string) { - super(`Invalid preview URL: ${rawUrl} (${detail})`); - this.name = "PreviewUrlNormalizationError"; - this.rawUrl = rawUrl; - this.detail = detail; +export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( + "PreviewUrlNormalizationError", + { + inputLength: Schema.Number, + reason: Schema.Literals(["empty", "parse", "unsupported-protocol"]), + protocol: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`; + return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`; } } +export const isPreviewUrlNormalizationError = Schema.is(PreviewUrlNormalizationError); + +function previewUrlProtocol(rawUrl: string): string | undefined { + return /^([A-Za-z][A-Za-z\d+.-]*):/.exec(rawUrl)?.[1]?.toLowerCase().concat(":"); +} + /** * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. * @@ -69,7 +81,7 @@ export class PreviewUrlNormalizationError extends Error { export function normalizePreviewUrl(rawUrl: string): string { const trimmed = rawUrl.trim(); if (trimmed.length === 0) { - throw new PreviewUrlNormalizationError(rawUrl, "empty"); + throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length, reason: "empty" }); } const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); const candidate = trimmed.includes("://") @@ -79,13 +91,19 @@ export function normalizePreviewUrl(rawUrl: string): string { try { parsed = new URL(candidate); } catch (cause) { - throw new PreviewUrlNormalizationError( - rawUrl, - cause instanceof Error ? cause.message : "unparseable", - ); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "parse", + protocol: previewUrlProtocol(candidate), + cause, + }); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + throw new PreviewUrlNormalizationError({ + inputLength: rawUrl.length, + reason: "unsupported-protocol", + protocol: parsed.protocol, + }); } return parsed.href; } From d8bf307d22663a3c458dc521a014fd21bdb60b98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:37:50 -0700 Subject: [PATCH 62/66] [codex] Enrich process runner errors (#3268) Co-authored-by: codex --- .../ServerEnvironmentLabel.test.ts | 2 +- apps/server/src/processRunner.test.ts | 92 ++++++++++++++++++- apps/server/src/processRunner.ts | 65 +++++++++---- 3 files changed, 135 insertions(+), 24 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/ServerEnvironmentLabel.test.ts index bc30bd0ce195..4bc9647fba5d 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.test.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.test.ts @@ -138,7 +138,7 @@ describe("resolveServerEnvironmentLabel", () => { Effect.fail( new ProcessRunner.ProcessSpawnError({ command: "scutil", - args: ["--get", "ComputerName"], + argumentCount: 2, cause: new Error("spawn scutil ENOENT"), }), ), diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 2c9d9f950382..e264ba7849da 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; @@ -55,7 +56,9 @@ function makeHandle(input: { } function makeSpawner( - f: (command: ChildProcessCommand) => Effect.Effect, + f: ( + command: ChildProcessCommand, + ) => Effect.Effect, ) { return ChildProcessSpawner.make((command) => f(asChildProcessCommand(command))); } @@ -159,6 +162,44 @@ describe("runProcess", () => { ); }); + it.effect("preserves resolved spawn context and cause", () => + Effect.gen(function* () { + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcessSpawner", + method: "spawn", + pathOrDescriptor: "/actual/fake", + }); + const spawner = makeSpawner(() => Effect.fail(cause)); + + const error = yield* runWith(spawner)({ + command: "fake", + args: ["--flag", "secret-token-value"], + cwd: "/logical", + spawnCwd: "/actual", + }).pipe(Effect.flip); + + expect(error._tag).toBe("ProcessSpawnError"); + if (error._tag !== "ProcessSpawnError") { + return expect.fail("Expected ProcessSpawnError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 2, + cwd: "/logical", + spawnCwd: "/actual", + resolvedCommand: "fake", + resolvedArgumentCount: 2, + shell: false, + }); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to spawn process 'fake' in '/actual'"); + expect(error).not.toHaveProperty("args"); + expect(error).not.toHaveProperty("resolvedArgs"); + expect(error.message).not.toContain("secret-token-value"); + }), + ); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); @@ -169,7 +210,39 @@ describe("runProcess", () => { maxOutputBytes: 128, }).pipe(Effect.flip); - expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError); + expect(error._tag).toBe("ProcessOutputLimitError"); + if (error._tag !== "ProcessOutputLimitError") { + return expect.fail("Expected ProcessOutputLimitError"); + } + expect(error).toMatchObject({ + stream: "stdout", + maxBytes: 128, + observedBytes: 2048, + }); + expect(error.message).toBe( + "Process 'fake' stdout produced 2048 bytes, exceeding the 128 byte limit", + ); + }), + ); + + it.effect("accepts output at the byte limit followed by an empty chunk", () => + Effect.gen(function* () { + const output = new TextEncoder().encode("exactly"); + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdout: Stream.make(output, new Uint8Array()), + }), + ), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["exact-limit"], + maxOutputBytes: output.byteLength, + }); + + expect(result.stdout).toBe("exactly"); }), ); @@ -272,6 +345,8 @@ describe("runProcess", () => { const errorFiber = yield* runWith(spawner)({ command: "fake", args: ["sleep"], + cwd: "/logical", + spawnCwd: "/actual", timeout: "50 millis", }).pipe(Effect.flip, Effect.forkScoped); @@ -279,7 +354,18 @@ describe("runProcess", () => { yield* TestClock.adjust(Duration.millis(50)); const error = yield* Fiber.join(errorFiber); - expect(error).toBeInstanceOf(ProcessRunner.ProcessTimeoutError); + expect(error._tag).toBe("ProcessTimeoutError"); + if (error._tag !== "ProcessTimeoutError") { + return expect.fail("Expected ProcessTimeoutError"); + } + expect(error).toMatchObject({ + command: "fake", + argumentCount: 1, + cwd: "/logical", + spawnCwd: "/actual", + timeoutMs: 50, + }); + expect(error.message).toBe("Process 'fake' in '/actual' timed out after 50ms"); }), ); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5f01fcc344be..c1ee2b2cb0c9 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -45,23 +45,29 @@ export interface ProcessRunOutput { const ProcessInvocationFields = { command: Schema.String, - args: Schema.Array(Schema.String), + argumentCount: Schema.Number, cwd: Schema.optional(Schema.String), + spawnCwd: Schema.optional(Schema.String), }; const formatProcessInvocation = (input: { readonly command: string; - readonly args: ReadonlyArray; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; }): string => { - const command = [input.command, ...input.args].join(" "); - return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`; + const executionCwd = input.spawnCwd ?? input.cwd; + return executionCwd === undefined + ? `'${input.command}'` + : `'${input.command}' in '${executionCwd}'`; }; export class ProcessSpawnError extends Schema.TaggedErrorClass()( "ProcessSpawnError", { ...ProcessInvocationFields, + resolvedCommand: Schema.optional(Schema.String), + resolvedArgumentCount: Schema.optional(Schema.Number), + shell: Schema.optional(Schema.Boolean), cause: Schema.Defect(), }, ) { @@ -74,6 +80,7 @@ export class ProcessStdinError extends Schema.TaggedErrorClass; readonly cwd?: string | undefined; + readonly spawnCwd?: string | undefined; readonly streamName: "stdout" | "stderr"; readonly stream: Stream.Stream; readonly maxOutputBytes: number; @@ -174,8 +185,9 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, cause, }), @@ -203,14 +215,16 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { () => ({ chunks: [], bytes: 0 }), (state, chunk) => { const remainingBytes = input.maxOutputBytes - state.bytes; - if (remainingBytes <= 0 || chunk.byteLength > remainingBytes) { + if (chunk.byteLength > remainingBytes) { return Effect.fail( new ProcessOutputLimitError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: input.streamName, maxBytes: input.maxOutputBytes, + observedBytes: state.bytes + chunk.byteLength, }), ); } @@ -259,8 +273,9 @@ function finalizeRunProcess( return Effect.fail( new ProcessTimeoutError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, timeoutMs: Duration.toMillis(timeout), }), ); @@ -300,23 +315,30 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessSpawnError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + resolvedCommand: spawnCommand.command, + resolvedArgumentCount: spawnCommand.args.length, + shell: spawnCommand.shell, cause, }), ), ); + const stdin = input.stdin; const writeStdin = - input.stdin === undefined + stdin === undefined ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( Effect.mapError( (cause) => new ProcessStdinError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, + stdinBytes: Buffer.byteLength(stdin), cause, }), ), @@ -328,6 +350,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stdout", stream: child.stdout, maxOutputBytes, @@ -338,6 +361,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( command: input.command, args: input.args, cwd: input.cwd, + spawnCwd: input.spawnCwd, streamName: "stderr", stream: child.stderr, maxOutputBytes, @@ -354,8 +378,9 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( (cause) => new ProcessReadError({ command: input.command, - args: input.args, + argumentCount: input.args.length, cwd: input.cwd, + spawnCwd: input.spawnCwd, stream: "exitCode", cause, }), From b6e384fb273b56ab9d12237f27684d1392be1692 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:40:08 -0700 Subject: [PATCH 63/66] [codex] Preserve trace IDs across error causes (#3426) Co-authored-by: codex --- .../src/errors/errorTrace.test.ts | 19 ++++++++++ .../client-runtime/src/errors/errorTrace.ts | 38 +++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/errors/errorTrace.test.ts b/packages/client-runtime/src/errors/errorTrace.test.ts index 075049bd55ef..255098999952 100644 --- a/packages/client-runtime/src/errors/errorTrace.test.ts +++ b/packages/client-runtime/src/errors/errorTrace.test.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import { describe, expect, it } from "vite-plus/test"; import { findErrorTraceId } from "./errorTrace.ts"; @@ -22,4 +23,22 @@ describe("findErrorTraceId", () => { expect(findErrorTraceId(error)).toBeNull(); }); + + it("finds trace metadata in Effect cause branches", () => { + const cause = Cause.fromReasons([ + Cause.makeFailReason(new Error("first failure")), + Cause.makeFailReason({ traceId: "trace-secondary" }), + ]); + + expect(findErrorTraceId(cause)).toBe("trace-secondary"); + }); + + it("finds trace metadata in aggregate error branches", () => { + const error = new AggregateError( + [new Error("first failure"), { traceId: "trace-aggregate" }], + "request failed", + ); + + expect(findErrorTraceId(error)).toBe("trace-aggregate"); + }); }); diff --git a/packages/client-runtime/src/errors/errorTrace.ts b/packages/client-runtime/src/errors/errorTrace.ts index ec1b2a6b2cd9..74deb37c4f3f 100644 --- a/packages/client-runtime/src/errors/errorTrace.ts +++ b/packages/client-runtime/src/errors/errorTrace.ts @@ -1,17 +1,49 @@ +import * as Cause from "effect/Cause"; + +const MAX_ERROR_TRACE_NODES = 128; + export function findErrorTraceId(error: unknown): string | null { const seen = new Set(); - let current: unknown = error; + const pending: Array = [error]; + let inspectedNodeCount = 0; - while (typeof current === "object" && current !== null && !seen.has(current)) { + while (pending.length > 0 && inspectedNodeCount < MAX_ERROR_TRACE_NODES) { + const current = pending.pop(); + inspectedNodeCount += 1; + if (typeof current !== "object" || current === null || seen.has(current)) { + continue; + } seen.add(current); const record = current as { readonly cause?: unknown; + readonly errors?: unknown; readonly traceId?: unknown; }; if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { return record.traceId; } - current = record.cause; + + if (Array.isArray(record.errors)) { + for (let index = record.errors.length - 1; index >= 0; index -= 1) { + pending.push(record.errors[index]); + } + } + if (Cause.isCause(current)) { + for (let index = current.reasons.length - 1; index >= 0; index -= 1) { + const reason = current.reasons[index]; + switch (reason?._tag) { + case "Fail": + pending.push(reason.error); + break; + case "Die": + pending.push(reason.defect); + break; + } + } + } + if ("cause" in record) { + pending.push(record.cause); + } } return null; From 9c6b45ba6ef8525bb2ef8bcf0e22a147cc50ca74 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 12:21:59 +0530 Subject: [PATCH 64/66] Fix preview CI checks --- apps/web/src/components/chat/ChatComposer.tsx | 114 +++++++++--------- apps/web/src/rpc/requestLatencyState.ts | 5 + 2 files changed, 62 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 933d923b44a6..826739a7a63f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2313,64 +2313,64 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), ) .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + + ))} )} diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 950e907e8650..1d8663edcfdd 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; @@ -36,6 +37,10 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { + if (tag === WS_METHODS.previewAutomationConnect) { + return false; + } + // Skip subscribe RPCs (they are long-lived streams and the ack arrives much // later than the user-visible payload). Match `subscribe` at the start of // the tag or after a path-segment delimiter so `thread/unsubscribe` and From 72b47484f6859d49a756681127cebbeaefab7175 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 12:30:23 +0530 Subject: [PATCH 65/66] Fix provider diagnostics checks --- apps/server/src/ampServerManager.ts | 31 ++--- apps/server/src/checkpointing/Utils.ts | 5 +- apps/server/src/commandPath.ts | 16 +-- .../server/src/geminiCliServerManager.test.ts | 5 +- apps/server/src/geminiCliServerManager.ts | 63 +++++----- apps/server/src/kilo/eventHandlers.test.ts | 1 + apps/server/src/kilo/eventHandlers.ts | 4 +- apps/server/src/kilo/serverLifecycle.ts | 21 ++-- apps/server/src/kilo/utils.ts | 7 +- apps/server/src/kiloServerManager.test.ts | 1 + apps/server/src/kiloServerManager.ts | 8 +- apps/server/src/logger.ts | 5 +- .../Layers/OrchestrationEventStore.test.ts | 1 + .../src/provider/Layers/AmpAdapter.test.ts | 27 ++-- .../server/src/provider/Layers/AmpProvider.ts | 2 + .../provider/Layers/CopilotAdapter.test.ts | 21 ++-- .../src/provider/Layers/CopilotAdapter.ts | 11 +- .../provider/Layers/CopilotProvider.test.ts | 12 +- .../src/provider/Layers/CopilotProvider.ts | 2 + .../src/provider/Layers/DroidAdapter.test.ts | 117 +++++++++--------- .../src/provider/Layers/DroidAdapter.ts | 8 +- .../src/provider/Layers/DroidProvider.ts | 5 +- .../provider/Layers/GeminiCliAdapter.test.ts | 21 ++-- .../provider/Layers/GeminiCliProvider.test.ts | 6 +- .../src/provider/Layers/GeminiCliProvider.ts | 2 + .../src/provider/Layers/KiloAdapter.test.ts | 21 ++-- .../src/provider/Layers/KiloProvider.test.ts | 20 +-- .../src/provider/Layers/KiloProvider.ts | 2 + .../src/provider/Layers/copilotCliPath.ts | 45 ++++--- .../src/provider/droid/DroidRuntimeEvents.ts | 4 +- apps/server/src/vcs/GitVcsDriverCore.ts | 5 +- apps/server/vite.config.ts | 4 +- scripts/lib/macos-icon-composer.ts | 33 ++--- scripts/sync-upstream-pr-tracks.mjs | 14 +-- 34 files changed, 299 insertions(+), 251 deletions(-) diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 7acd8b77d4e4..895f96185b3b 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import readline from "node:readline"; +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Provider process manager owns child process lifecycle and timestamped runtime events. +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeReadline from "node:readline"; import { ApprovalRequestId, @@ -44,8 +45,8 @@ type AmpProviderOptions = { interface AmpSession { readonly threadId: ThreadId; - readonly process: ChildProcessWithoutNullStreams; - readonly rl: readline.Interface; + readonly process: NodeChildProcess.ChildProcessWithoutNullStreams; + readonly rl: NodeReadline.Interface; model: string | undefined; cwd: string; runtimeMode: string; @@ -144,7 +145,7 @@ interface AmpJsonlMessage { // ── Manager ───────────────────────────────────────────────────────── -export class AmpServerManager extends EventEmitter<{ +export class AmpServerManager extends NodeEvents.EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); @@ -209,13 +210,13 @@ export class AmpServerManager extends EventEmitter<{ args.push("--dangerously-allow-all"); } - const child = spawn(binaryPath, args, { + const child = NodeChildProcess.spawn(binaryPath, args, { cwd, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, }); - const rl = readline.createInterface({ input: child.stdout }); + const rl = NodeReadline.createInterface({ input: child.stdout }); const session: AmpSession = { threadId, @@ -343,7 +344,7 @@ export class AmpServerManager extends EventEmitter<{ throw new Error("Attachments are not supported by AMP"); } - const turnId = TurnId.make(randomUUID()); + const turnId = TurnId.make(NodeCrypto.randomUUID()); const prompt = input.input ?? ""; // Write a JSONL user message to stdin for the persistent AMP process. @@ -611,7 +612,7 @@ export class AmpServerManager extends EventEmitter<{ switch (block.type) { case "text": { if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent( threadId, @@ -630,7 +631,7 @@ export class AmpServerManager extends EventEmitter<{ case "thinking": { if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent( threadId, @@ -687,7 +688,7 @@ export class AmpServerManager extends EventEmitter<{ const existing = session.subagentTasks.get(parentToolUseId); if (!existing) { // First occurrence — emit task.started. - const taskId = RuntimeTaskId.make(randomUUID()); + const taskId = RuntimeTaskId.make(NodeCrypto.randomUUID()); session.subagentTasks.set(parentToolUseId, taskId); this.emitEvent(threadId, session.activeTurnId, { type: "task.started", @@ -831,7 +832,7 @@ export class AmpServerManager extends EventEmitter<{ ): void { const event = { type: partial.type, - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: PROVIDER, createdAt: new Date().toISOString(), threadId, @@ -839,7 +840,7 @@ export class AmpServerManager extends EventEmitter<{ ...(itemId ? { itemId } : partial.type === "content.delta" - ? { itemId: RuntimeItemId.make(randomUUID()) } + ? { itemId: RuntimeItemId.make(NodeCrypto.randomUUID()) } : {}), payload: partial.payload, } as unknown as ProviderRuntimeEvent; diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index c4802efdef71..cc0564ceee08 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +// @effect-diagnostics nodeBuiltinImport:off - Checkpoint utility probes filesystem paths directly. +import * as NodeFS from "node:fs"; import * as Encoding from "effect/Encoding"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; @@ -43,5 +44,5 @@ export function resolveExistingThreadWorkspaceCwd(input: { if (!resolvedCwd) { return undefined; } - return existsSync(resolvedCwd) ? resolvedCwd : undefined; + return NodeFS.existsSync(resolvedCwd) ? resolvedCwd : undefined; } diff --git a/apps/server/src/commandPath.ts b/apps/server/src/commandPath.ts index a45c4cda4247..6c3e1736f0a4 100644 --- a/apps/server/src/commandPath.ts +++ b/apps/server/src/commandPath.ts @@ -1,5 +1,6 @@ -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off - Pure command path resolver intentionally mirrors host PATH lookup. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; interface CommandPathOptions { readonly platform?: NodeJS.Platform; @@ -41,7 +42,7 @@ function resolveCommandCandidates( windowsPathExtensions: ReadonlyArray, ): ReadonlyArray { if (platform !== "win32") return [command]; - const extension = extname(command); + const extension = NodePath.extname(command); const normalizedExtension = extension.toUpperCase(); if (extension.length > 0) { @@ -80,16 +81,16 @@ function isExecutableFile( windowsPathExtensions: ReadonlyArray, ): boolean { try { - const stat = statSync(filePath); + const stat = NodeFS.statSync(filePath); if (!stat.isFile()) return false; if (platform === "win32") { - const extension = extname(filePath); + const extension = NodePath.extname(filePath); if (extension.length === 0) return false; return new Set([...DEFAULT_WINDOWS_PATH_EXTENSIONS, ...windowsPathExtensions]).has( extension.toUpperCase(), ); } - accessSync(filePath, constants.X_OK); + NodeFS.accessSync(filePath, NodeFS.constants.X_OK); return true; } catch { return false; @@ -100,6 +101,7 @@ export function resolveCommandPath( command: string, options: CommandPathOptions = {}, ): string | undefined { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure utility keeps an optional injectable platform for tests and non-Effect call sites. const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; @@ -121,7 +123,7 @@ export function resolveCommandPath( for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { - const resolvedPath = join(pathEntry, candidate); + const resolvedPath = NodePath.join(pathEntry, candidate); if (isExecutableFile(resolvedPath, platform, windowsPathExtensions)) { return resolvedPath; } diff --git a/apps/server/src/geminiCliServerManager.test.ts b/apps/server/src/geminiCliServerManager.test.ts index 5757ecc090ca..c9a916ac2db9 100644 --- a/apps/server/src/geminiCliServerManager.test.ts +++ b/apps/server/src/geminiCliServerManager.test.ts @@ -1,5 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Tests exercise Node path-like inputs and timestamped provider events. import { describe, expect, it, vi, beforeEach } from "vite-plus/test"; -import type { PathLike } from "node:fs"; +import * as NodeFS from "node:fs"; import { ProviderDriverKind, ProviderInstanceId, @@ -65,7 +66,7 @@ describe("GeminiCliServerManager", () => { } return undefined; }, - existsSync: (path: PathLike) => + existsSync: (path: NodeFS.PathLike) => String(path).replace(/\\/g, "/") === "C:/Users/user/AppData/Roaming/npm/node_modules/@google/gemini-cli/dist/index.js", }, diff --git a/apps/server/src/geminiCliServerManager.ts b/apps/server/src/geminiCliServerManager.ts index 5ab113f56d19..08fd6e6f8abd 100644 --- a/apps/server/src/geminiCliServerManager.ts +++ b/apps/server/src/geminiCliServerManager.ts @@ -1,14 +1,10 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; -import { extname, win32 as win32Path } from "node:path"; -import { - spawn, - spawnSync, - type ChildProcess, - type ChildProcessWithoutNullStreams, -} from "node:child_process"; -import readline from "node:readline"; +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Provider process manager owns child process lifecycle and timestamped runtime events. +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeReadline from "node:readline"; import { ApprovalRequestId, @@ -137,7 +133,7 @@ interface GeminiCliSession { /** Gemini-native session ID for --resume. */ geminiSessionId: string | undefined; activeTurnId: TurnId | undefined; - activeProcess: ChildProcess | undefined; + activeProcess: NodeChildProcess.ChildProcess | undefined; interruptedTurnId: TurnId | undefined; /** Stable itemId for the current turn's assistant message (reused across content.delta events). */ activeAssistantItemId: RuntimeItemId | undefined; @@ -179,19 +175,19 @@ interface GeminiSpawnPlan { interface GeminiSpawnPlanDependencies { readonly resolveCommandPath?: typeof resolveCommandPath; - readonly existsSync?: typeof existsSync; + readonly existsSync?: typeof NodeFS.existsSync; } function resolveGeminiShimEntryPoint( binaryPath: string, - fileExists: typeof existsSync = existsSync, + fileExists: typeof NodeFS.existsSync = NodeFS.existsSync, ): string | undefined { - if (![".cmd", ".bat"].includes(extname(binaryPath).toLowerCase())) { + if (![".cmd", ".bat"].includes(NodePath.extname(binaryPath).toLowerCase())) { return undefined; } - const shimDirectory = win32Path.dirname(binaryPath); - const shimEntryPoint = win32Path.join( + const shimDirectory = NodePath.win32.dirname(binaryPath); + const shimEntryPoint = NodePath.win32.join( shimDirectory, "node_modules", "@google", @@ -205,6 +201,7 @@ function resolveGeminiShimEntryPoint( function resolveNodeCommand( env: NodeJS.ProcessEnv, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure helper keeps platform injectable for tests and non-Effect callers. platform: NodeJS.Platform = process.platform, commandPathResolver: typeof resolveCommandPath = resolveCommandPath, ): string { @@ -221,11 +218,12 @@ export function resolveGeminiSpawnPlan( readonly cwd: string; readonly env: NodeJS.ProcessEnv; }, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure spawn planner keeps platform injectable for tests and class callers. platform: NodeJS.Platform = process.platform, dependencies: GeminiSpawnPlanDependencies = {}, ): GeminiSpawnPlan { const commandPathResolver = dependencies.resolveCommandPath ?? resolveCommandPath; - const fileExists = dependencies.existsSync ?? existsSync; + const fileExists = dependencies.existsSync ?? NodeFS.existsSync; const options = buildGeminiSpawnOptions({ cwd: input.cwd, env: input.env, @@ -245,7 +243,7 @@ export function resolveGeminiSpawnPlan( env: input.env, }) ?? input.binaryPath; - if (extname(resolvedBinaryPath).toLowerCase() === ".js") { + if (NodePath.extname(resolvedBinaryPath).toLowerCase() === ".js") { return { command: resolveNodeCommand(input.env, platform, commandPathResolver), args: [resolvedBinaryPath, ...input.args], @@ -269,10 +267,17 @@ export function resolveGeminiSpawnPlan( }; } -function killGeminiChildProcess(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void { - if (process.platform === "win32" && child.pid !== undefined) { +function killGeminiChildProcess( + child: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals = "SIGTERM", + // oxlint-disable-next-line t3code/no-global-process-runtime -- Manager is a non-Effect process owner; tests can pass a platform explicitly. + platform: NodeJS.Platform = process.platform, +): void { + if (platform === "win32" && child.pid !== undefined) { try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + NodeChildProcess.spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); return; } catch { // Fall back to direct kill when taskkill is unavailable. @@ -324,7 +329,7 @@ function resolveApprovalMode(runtimeMode: string): string { } } -export class GeminiCliServerManager extends EventEmitter<{ +export class GeminiCliServerManager extends NodeEvents.EventEmitter<{ event: [ProviderRuntimeEvent]; }> { private readonly sessions = new Map(); @@ -435,7 +440,7 @@ export class GeminiCliServerManager extends EventEmitter<{ throw new Error("Gemini CLI does not support attachments"); } - const turnId = TurnId.make(randomUUID()); + const turnId = TurnId.make(NodeCrypto.randomUUID()); session.activeTurnId = turnId; session.status = "running"; session.updatedAt = new Date().toISOString(); @@ -474,7 +479,7 @@ export class GeminiCliServerManager extends EventEmitter<{ env: { ...process.env }, }); - const child: ChildProcessWithoutNullStreams = spawn( + const child: NodeChildProcess.ChildProcessWithoutNullStreams = NodeChildProcess.spawn( spawnPlan.command, [...spawnPlan.args], spawnPlan.options, @@ -491,7 +496,7 @@ export class GeminiCliServerManager extends EventEmitter<{ }); let stderrSummary = ""; - const rl = readline.createInterface({ input: child.stdout }); + const rl = NodeReadline.createInterface({ input: child.stdout }); rl.on("line", (line) => { this.handleJsonLine(input.threadId, turnId, line); @@ -695,7 +700,7 @@ export class GeminiCliServerManager extends EventEmitter<{ if (event.role === "assistant" && event.content) { // Reuse a stable itemId so all deltas aggregate into one assistant message. if (!session.activeAssistantItemId) { - session.activeAssistantItemId = RuntimeItemId.make(randomUUID()); + session.activeAssistantItemId = RuntimeItemId.make(NodeCrypto.randomUUID()); } this.emitEvent(threadId, turnId, { type: "content.delta", @@ -724,7 +729,7 @@ export class GeminiCliServerManager extends EventEmitter<{ session.activeAssistantItemId = undefined; } - const itemId = RuntimeItemId.make(randomUUID()); + const itemId = RuntimeItemId.make(NodeCrypto.randomUUID()); const toolTitle = summarizeToolCall(event.tool_name, event.parameters); const paramSummary = typeof event.parameters === "object" ? JSON.stringify(event.parameters) : undefined; @@ -862,7 +867,7 @@ export class GeminiCliServerManager extends EventEmitter<{ ): void { const event = { type: partial.type, - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: PROVIDER, createdAt: new Date().toISOString(), threadId, diff --git a/apps/server/src/kilo/eventHandlers.test.ts b/apps/server/src/kilo/eventHandlers.test.ts index 82496ea2e801..fba1f115647d 100644 --- a/apps/server/src/kilo/eventHandlers.test.ts +++ b/apps/server/src/kilo/eventHandlers.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off - Tests build timestamped Kilo server events. import { ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/server/src/kilo/eventHandlers.ts b/apps/server/src/kilo/eventHandlers.ts index a529f30fcddc..2a75aeb69605 100644 --- a/apps/server/src/kilo/eventHandlers.ts +++ b/apps/server/src/kilo/eventHandlers.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { ApprovalRequestId, RuntimeItemId, RuntimeRequestId } from "@t3tools/contracts"; @@ -838,7 +838,7 @@ function handleCommandExecutedEvent( if (sessionID !== context.providerSessionId) { return; } - const itemId = RuntimeItemId.make(`cmd:${command}:${randomUUID()}`); + const itemId = RuntimeItemId.make(`cmd:${command}:${NodeCrypto.randomUUID()}`); const title = `Command: ${command}`; emitter.emitRuntimeEvent({ type: "item.started", diff --git a/apps/server/src/kilo/serverLifecycle.ts b/apps/server/src/kilo/serverLifecycle.ts index 1aacd5459d94..cd2b9e7708f6 100644 --- a/apps/server/src/kilo/serverLifecycle.ts +++ b/apps/server/src/kilo/serverLifecycle.ts @@ -1,4 +1,5 @@ -import { spawn } from "node:child_process"; +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalTimers:off - Kilo lifecycle helper owns raw process, HTTP readiness polling, and timeout boundaries. +import * as NodeChildProcess from "node:child_process"; import { DEFAULT_HOSTNAME, @@ -84,14 +85,18 @@ async function spawnOrConnect(options?: KiloProviderOptions): Promise((resolve, reject) => { let output = ""; diff --git a/apps/server/src/kilo/utils.ts b/apps/server/src/kilo/utils.ts index ceb647ae3025..8e5a8066abf4 100644 --- a/apps/server/src/kilo/utils.ts +++ b/apps/server/src/kilo/utils.ts @@ -1,4 +1,5 @@ -import { randomUUID } from "node:crypto"; +// @effect-diagnostics globalDate:off - Kilo protocol payloads require ISO timestamps. +import * as NodeCrypto from "node:crypto"; import { EventId, @@ -36,7 +37,7 @@ export function asString(value: unknown): string | undefined { } export function eventId(prefix: string): EventId { - return EventId.make(`${prefix}:${randomUUID()}`); + return EventId.make(`${prefix}:${NodeCrypto.randomUUID()}`); } export function nowIso(): string { @@ -44,7 +45,7 @@ export function nowIso(): string { } export function createTurnId(): TurnId { - return TurnId.make(`turn:${randomUUID()}`); + return TurnId.make(`turn:${NodeCrypto.randomUUID()}`); } export function textPart(text: string) { diff --git a/apps/server/src/kiloServerManager.test.ts b/apps/server/src/kiloServerManager.test.ts index 4f54348d956b..e8f55ef78598 100644 --- a/apps/server/src/kiloServerManager.test.ts +++ b/apps/server/src/kiloServerManager.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalTimers:off - Tests build timestamped Kilo events and wait for async lifecycle behavior. import { ApprovalRequestId, ThreadId, TurnId, type ProviderRuntimeEvent } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; diff --git a/apps/server/src/kiloServerManager.ts b/apps/server/src/kiloServerManager.ts index d1537a3ad3c5..d9d7ceed9b95 100644 --- a/apps/server/src/kiloServerManager.ts +++ b/apps/server/src/kiloServerManager.ts @@ -1,5 +1,5 @@ -import { randomUUID } from "node:crypto"; -import { EventEmitter } from "node:events"; +import * as NodeCrypto from "node:crypto"; +import * as NodeEvents from "node:events"; import { ApprovalRequestId, @@ -50,7 +50,7 @@ import { createClient, ensureServer } from "./kilo/serverLifecycle.ts"; export { type KiloDiscoveredModel, type KiloModelDiscoveryOptions } from "./kilo/types.ts"; -export class KiloServerManager extends EventEmitter { +export class KiloServerManager extends NodeEvents.EventEmitter { private readonly sessions = new Map(); private serverPromise: Promise | undefined; private server: SharedServerState | undefined; @@ -436,7 +436,7 @@ export class KiloServerManager extends EventEmitter { const turns = (Array.isArray(messages) ? messages : []).map((entry) => { const info = asRecord(asRecord(entry)?.info); - const messageId = asString(info?.id) ?? randomUUID(); + const messageId = asString(info?.id) ?? NodeCrypto.randomUUID(); return { id: TurnId.make(messageId), items: [entry], diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts index b9d18569ccfd..2a561d2dccf7 100644 --- a/apps/server/src/logger.ts +++ b/apps/server/src/logger.ts @@ -1,4 +1,5 @@ -import util from "node:util"; +// @effect-diagnostics globalDate:off globalConsole:off - Minimal process logger intentionally writes timestamped console output. +import * as NodeUtil from "node:util"; type LogLevel = "info" | "warn" | "error" | "event"; @@ -51,7 +52,7 @@ function formatValue(value: unknown) { ) { return String(value); } - return util.inspect(value, { + return NodeUtil.inspect(value, { depth: 4, breakLength: Infinity, compact: true, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts index b7fd7a230561..ed894603fb34 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDateInEffect:off preferSchemaOverJson:off - Persistence tests assert raw stored JSON and timestamp behavior. import { CommandId, EventId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/provider/Layers/AmpAdapter.test.ts b/apps/server/src/provider/Layers/AmpAdapter.test.ts index dcd5ba6583ea..f0230f05475d 100644 --- a/apps/server/src/provider/Layers/AmpAdapter.test.ts +++ b/apps/server/src/provider/Layers/AmpAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ApprovalRequestId, @@ -130,8 +131,8 @@ it.effect("AmpAdapter delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "amp"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "amp"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }).pipe(Effect.scoped), ); @@ -147,11 +148,11 @@ it.effect("AmpAdapter rejects startSession when provider is disabled", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -168,11 +169,11 @@ it.effect("AmpAdapter rejects attachments until AMP attachment wiring exists", ( }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -185,11 +186,11 @@ it.effect("AmpAdapter rejects rollbackThread with non-positive numTurns", () => .rollbackThread(asThreadId("thread-rollback"), 0) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.scoped), ); @@ -200,7 +201,7 @@ it.effect("AmpAdapter forwards interruptTurn calls to the manager", () => yield* adapter.interruptTurn(asThreadId("thread-interrupt")); - assert.equal(manager.interruptTurnImpl.mock.calls.length, 1); + NodeAssert.equal(manager.interruptTurnImpl.mock.calls.length, 1); }).pipe(Effect.scoped), ); @@ -231,14 +232,14 @@ it.effect("AmpAdapter forwards manager runtime events through the adapter stream // resolves immediately without a race condition. const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/AmpProvider.ts b/apps/server/src/provider/Layers/AmpProvider.ts index 11a78e626661..31a44976e4aa 100644 --- a/apps/server/src/provider/Layers/AmpProvider.ts +++ b/apps/server/src/provider/Layers/AmpProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * AmpProvider — snapshot probe for the Amp CLI provider. * @@ -80,6 +81,7 @@ const runAmpCommand = Effect.fn("runAmpCommand")(function* ( const binaryPath = defaultBinaryPath(ampSettings); const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.test.ts b/apps/server/src/provider/Layers/CopilotAdapter.test.ts index e432217d79dc..ae837d00ee77 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.test.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import { type SessionEvent } from "@github/copilot-sdk"; @@ -163,12 +164,12 @@ modeLayer("CopilotAdapterLive interaction mode", (it) => { attachments: [], }); - assert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ + NodeAssert.deepStrictEqual(modeSession.modeSetImpl.mock.calls, [ [{ mode: "plan" }], [{ mode: "interactive" }], ]); - assert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "immediate"); - assert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "immediate"); + NodeAssert.equal(modeSession.sendImpl.mock.calls[0]?.[0]?.mode, "immediate"); + NodeAssert.equal(modeSession.sendImpl.mock.calls[1]?.[0]?.mode, "immediate"); }), ); }); @@ -229,16 +230,16 @@ planLayer("CopilotAdapterLive proposed plan events", (it) => { } satisfies SessionEvent); const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events[0]?.type, "turn.plan.updated"); + NodeAssert.equal(events[0]?.type, "turn.plan.updated"); if (events[0]?.type === "turn.plan.updated") { - assert.equal(events[0].turnId, turn.turnId); - assert.equal(events[0].payload.explanation, "Plan updated"); + NodeAssert.equal(events[0].turnId, turn.turnId); + NodeAssert.equal(events[0].payload.explanation, "Plan updated"); } - assert.equal(events[1]?.type, "turn.proposed.completed"); + NodeAssert.equal(events[1]?.type, "turn.proposed.completed"); if (events[1]?.type === "turn.proposed.completed") { - assert.equal(events[1].turnId, turn.turnId); - assert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); + NodeAssert.equal(events[1].turnId, turn.turnId); + NodeAssert.equal(events[1].payload.planMarkdown, "# Ship it\n\n- first\n- second"); } }), ); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 26f64a14331e..b20c2d31ce1e 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Adapter emits provider protocol timestamps. /** * CopilotAdapter — `ProviderAdapterShape` for the GitHub Copilot SDK runtime. * @@ -24,7 +25,7 @@ * etc.) is owned per `ActiveCopilotSession`, which itself lives inside * the per-driver-instance `sessions` map. */ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { EventId, @@ -184,7 +185,7 @@ interface CopilotClientHandle { } function makeEventId(prefix: string) { - return EventId.make(`${prefix}-${randomUUID()}`); + return EventId.make(`${prefix}-${NodeCrypto.randomUUID()}`); } function toTurnId(value: string | undefined): TurnId | undefined { @@ -1055,7 +1056,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( getRuntimeMode() === "full-access" ? Promise.resolve({ kind: "approved" }) : new Promise((resolve) => { - const requestId = `copilot-approval-${randomUUID()}`; + const requestId = `copilot-approval-${NodeCrypto.randomUUID()}`; const turnId = getCurrentTurnId(); pendingApprovalResolvers.set(requestId, { requestType: requestTypeFromPermissionRequest(request), @@ -1080,7 +1081,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const onUserInputRequest = (request: CopilotUserInputRequest) => new Promise((resolve) => { - const requestId = `copilot-user-input-${randomUUID()}`; + const requestId = `copilot-user-input-${NodeCrypto.randomUUID()}`; const turnId = getCurrentTurnId(); pendingUserInputResolvers.set(requestId, { request, @@ -1530,7 +1531,7 @@ export const makeCopilotAdapter = Effect.fn("makeCopilotAdapter")(function* ( const interactionMode = input.interactionMode ?? record.interactionMode ?? "default"; yield* syncInteractionMode(record, interactionMode); - const turnId = TurnId.make(`copilot-turn-${randomUUID()}`); + const turnId = TurnId.make(`copilot-turn-${NodeCrypto.randomUUID()}`); record.pendingTurnIds.push(turnId); record.currentTurnId = turnId; record.currentProviderTurnId = undefined; diff --git a/apps/server/src/provider/Layers/CopilotProvider.test.ts b/apps/server/src/provider/Layers/CopilotProvider.test.ts index 98cdad3ad1aa..848344a6111b 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.test.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as Schema from "effect/Schema"; import { describe, it } from "vite-plus/test"; @@ -22,15 +22,15 @@ describe("CopilotProvider reasoning effort", () => { const draft = makePendingCopilotProvider(settings); const model = draft.models[0]; - assert.ok(model, "expected at least one copilot model"); + NodeAssert.ok(model, "expected at least one copilot model"); const descriptors = model.capabilities?.optionDescriptors ?? []; const effort = descriptors.find((descriptor) => descriptor.id === "reasoningEffort"); if (!effort || effort.type !== "select") { - assert.fail("reasoningEffort select descriptor must be present"); + NodeAssert.fail("reasoningEffort select descriptor must be present"); } - assert.deepEqual( + NodeAssert.deepEqual( effort.options.map((option) => option.id), ["low", "medium", "high", "xhigh"], ); @@ -39,8 +39,8 @@ describe("CopilotProvider reasoning effort", () => { // selector dispatches nothing and the adapter's per-model validation is // skipped — preserving the prior "no effort" behavior on models that do // not advertise the picked effort in supportedReasoningEfforts. - assert.equal(effort.currentValue, undefined); - assert.ok( + NodeAssert.equal(effort.currentValue, undefined); + NodeAssert.ok( effort.options.every((option) => option.isDefault !== true), "no reasoningEffort option may be marked isDefault (opt-in)", ); diff --git a/apps/server/src/provider/Layers/CopilotProvider.ts b/apps/server/src/provider/Layers/CopilotProvider.ts index b3dde6cab094..b62a5fd32a39 100644 --- a/apps/server/src/provider/Layers/CopilotProvider.ts +++ b/apps/server/src/provider/Layers/CopilotProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * CopilotProvider — snapshot probe for the GitHub Copilot driver. * @@ -90,6 +91,7 @@ const runCopilotVersionCommand = Effect.fn("runCopilotVersionCommand")(function* ) { const command = ChildProcess.make(binaryPath, ["--version"], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/DroidAdapter.test.ts b/apps/server/src/provider/Layers/DroidAdapter.test.ts index 177f7a29dc46..e8716a4f823b 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.test.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off - Tests build timestamped Droid events. +import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { @@ -170,11 +171,11 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => yield* adapter.sendTurn({ threadId, input: "hello" }); const events = yield* joinIterableFiber(eventsFiber); - assert.equal(createOptions?.modelId, "claude-sonnet"); - assert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); - assert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); - assert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); - assert.deepEqual( + NodeAssert.equal(createOptions?.modelId, "claude-sonnet"); + NodeAssert.equal(createOptions?.autonomyLevel, AutonomyLevel.High); + NodeAssert.equal(createOptions?.interactionMode, DroidInteractionMode.Auto); + NodeAssert.equal(createOptions?.reasoningEffort, ReasoningEffort.High); + NodeAssert.deepEqual( events.map((event) => event.type), [ "session.started", @@ -202,11 +203,11 @@ it.effect("maps Droid SDK stream messages into canonical runtime events", () => lastOutputTokens: 5, lastReasoningOutputTokens: 1, }; - assert.deepEqual( + NodeAssert.deepEqual( events.find((event) => event.type === "thread.token-usage.updated")?.payload, { usage: expectedUsage }, ); - assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + NodeAssert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { state: "completed", usage: expectedUsage, }); @@ -279,7 +280,7 @@ it.effect("keeps Droid token usage cumulative across turns", () => const usageEvents = events.filter((event) => event.type === "thread.token-usage.updated"); const completedTurns = events.filter((event) => event.type === "turn.completed"); - assert.deepEqual( + NodeAssert.deepEqual( usageEvents.map((event) => event.type === "thread.token-usage.updated" ? event.payload.usage : undefined, ), @@ -310,7 +311,7 @@ it.effect("keeps Droid token usage cumulative across turns", () => }, ], ); - assert.deepEqual( + NodeAssert.deepEqual( completedTurns.map((event) => event.type === "turn.completed" ? (event.payload as { usage?: { usedTokens?: number } }).usage?.usedTokens @@ -343,7 +344,7 @@ it.effect("maps Droid medium access to medium autonomy", () => runtimeMode: "medium-access", }); - assert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); + NodeAssert.equal(createOptions?.autonomyLevel, AutonomyLevel.Medium); }), ).pipe(Effect.provide(testLayer)), ); @@ -378,8 +379,8 @@ it.effect("applies runtime autonomy when resuming Droid sessions and sending tur yield* adapter.sendTurn({ threadId, input: "hello" }); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.deepEqual(updateSettingsCalls, [ + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.deepEqual(updateSettingsCalls, [ { autonomyLevel: AutonomyLevel.Off }, { autonomyLevel: AutonomyLevel.Off }, ]); @@ -419,11 +420,11 @@ it.effect("closes an existing Droid session before replacing the same thread", ( runtimeMode: "full-access", }); - assert.deepEqual(closedSessionIds, ["droid-session-1"]); - assert.equal(secondSession.resumeCursor, "droid-session-2"); + NodeAssert.deepEqual(closedSessionIds, ["droid-session-1"]); + NodeAssert.equal(secondSession.resumeCursor, "droid-session-2"); const sessions = yield* adapter.listSessions(); - assert.equal(sessions.length, 1); - assert.equal(sessions[0]?.resumeCursor, "droid-session-2"); + NodeAssert.equal(sessions.length, 1); + NodeAssert.equal(sessions[0]?.resumeCursor, "droid-session-2"); }), ).pipe(Effect.provide(testLayer)), ); @@ -467,7 +468,7 @@ it.effect("uses final Droid create_message content when deltas are absent", () = const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), [ { streamKind: "reasoning_text", delta: "final thought" }, @@ -475,10 +476,10 @@ it.effect("uses final Droid create_message content when deltas are absent", () = ], ); const completed = events.find((event) => event.type === "item.completed"); - assert.equal(completed?.type, "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { - assert.equal(completed.payload.itemType, "assistant_message"); - assert.equal(completed.payload.detail, "final text"); + NodeAssert.equal(completed.payload.itemType, "assistant_message"); + NodeAssert.equal(completed.payload.detail, "final text"); } }), ).pipe(Effect.provide(testLayer)), @@ -532,16 +533,16 @@ it.effect("does not duplicate Droid final create_message text after streaming de const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : undefined)), ["stre", "am"], ); const completed = events.find((event) => event.type === "item.completed"); - assert.equal(completed?.type, "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); if (completed?.type === "item.completed") { - assert.equal(completed.payload.detail, "stream"); + NodeAssert.equal(completed.payload.detail, "stream"); } - assert.equal( + NodeAssert.equal( events.filter( (event) => event.type === "item.completed" && event.payload.itemType === "assistant_message", @@ -586,9 +587,9 @@ it.effect("rejects concurrent Droid turns for the same thread", () => yield* adapter.sendTurn({ threadId, input: "first" }); const secondTurn = yield* adapter.sendTurn({ threadId, input: "second" }).pipe(Effect.exit); - assert.equal(secondTurn._tag, "Failure"); + NodeAssert.equal(secondTurn._tag, "Failure"); if (secondTurn._tag === "Failure") { - assert.match(String(secondTurn.cause), /already has an active turn/); + NodeAssert.match(String(secondTurn.cause), /already has an active turn/); } finishTurn?.(); @@ -653,7 +654,7 @@ it.effect("does not duplicate Droid final thinking content after streaming delta const events = yield* joinIterableFiber(eventsFiber); const deltas = events.filter((event) => event.type === "content.delta"); - assert.deepEqual( + NodeAssert.deepEqual( deltas.map((event) => (event.type === "content.delta" ? event.payload : undefined)), [ { streamKind: "reasoning_text", delta: "thi" }, @@ -690,8 +691,8 @@ it.effect("ignores Droid interrupt failures after aborting the active turn", () }); const exit = yield* adapter.interruptTurn(threadId).pipe(Effect.exit); - assert.equal(exit._tag, "Success"); - assert.equal(interruptAttempts, 1); + NodeAssert.equal(exit._tag, "Success"); + NodeAssert.equal(interruptAttempts, 1); }), ).pipe(Effect.provide(testLayer)), ); @@ -739,12 +740,12 @@ it.effect("passes custom model reasoning into Droid spec mode", () => }); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.deepEqual(enterSpecModeParams, { + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.deepEqual(enterSpecModeParams, { specModeModelId: "custom:Direct-GPT-5.5-xhigh-27", specModeReasoningEffort: ReasoningEffort.ExtraHigh, }); - assert.deepEqual(updateSettingsParams, { + NodeAssert.deepEqual(updateSettingsParams, { autonomyLevel: AutonomyLevel.High, modelId: "custom:Direct-GPT-5.5-xhigh-27", reasoningEffort: ReasoningEffort.ExtraHigh, @@ -807,17 +808,17 @@ it.effect("routes Droid permission requests through adapter approvals", () => }); yield* adapter.sendTurn({ threadId, input: "run lint" }); const opened = yield* Fiber.join(openedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(opened._tag, "Some"); + NodeAssert.equal(opened._tag, "Some"); const requestId = opened.value.requestId; - assert.ok(requestId); + NodeAssert.ok(requestId); yield* adapter.respondToRequest( threadId, ApprovalRequestId.make(requestId), "acceptForSession", ); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.equal(permissionResult, ToolConfirmationOutcome.ProceedAlways); }), ).pipe(Effect.provide(testLayer)), ); @@ -901,32 +902,32 @@ it.effect("settles pending Droid permission and user-input waits when stopped", }); yield* adapter.sendTurn({ threadId, input: "run lint" }); const openedEvents = yield* joinIterableFiber(openedEventsFiber); - assert.deepEqual(openedEvents.map((event) => event.type).toSorted(), [ + NodeAssert.deepEqual(openedEvents.map((event) => event.type).toSorted(), [ "request.opened", "user-input.requested", ]); yield* adapter.stopSession(threadId); const resolvedEvents = yield* joinIterableFiber(resolvedEventsFiber); - assert.deepEqual(resolvedEvents.map((event) => event.type).toSorted(), [ + NodeAssert.deepEqual(resolvedEvents.map((event) => event.type).toSorted(), [ "request.resolved", "user-input.resolved", ]); const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("2 seconds")); - assert.equal(completed._tag, "Some"); - assert.equal(permissionResult, ToolConfirmationOutcome.Cancel); - assert.deepEqual(userInputResult, { cancelled: true, answers: [] }); + NodeAssert.equal(completed._tag, "Some"); + NodeAssert.equal(permissionResult, ToolConfirmationOutcome.Cancel); + NodeAssert.deepEqual(userInputResult, { cancelled: true, answers: [] }); const resolvedApproval = resolvedEvents.find((event) => event.type === "request.resolved"); - assert.equal(resolvedApproval?.type, "request.resolved"); + NodeAssert.equal(resolvedApproval?.type, "request.resolved"); if (resolvedApproval?.type === "request.resolved") { - assert.equal(resolvedApproval.payload.decision, "cancel"); + NodeAssert.equal(resolvedApproval.payload.decision, "cancel"); } const resolvedUserInput = resolvedEvents.find( (event) => event.type === "user-input.resolved", ); - assert.equal(resolvedUserInput?.type, "user-input.resolved"); + NodeAssert.equal(resolvedUserInput?.type, "user-input.resolved"); if (resolvedUserInput?.type === "user-input.resolved") { - assert.deepEqual(resolvedUserInput.payload.answers, {}); + NodeAssert.deepEqual(resolvedUserInput.payload.answers, {}); } }), ).pipe(Effect.provide(testLayer)), @@ -969,12 +970,12 @@ it.effect("continues stopping Droid sessions when one close fails", () => yield* adapter.stopAll(); - assert.deepEqual(closedSessionIds.toSorted(), [ + NodeAssert.deepEqual(closedSessionIds.toSorted(), [ "droid-session-closes", "droid-session-fails-close", ]); const sessions = yield* adapter.listSessions(); - assert.deepEqual(sessions, []); + NodeAssert.deepEqual(sessions, []); }), ).pipe(Effect.provide(testLayer)), ); @@ -1016,8 +1017,8 @@ it.effect("marks Droid stream errors as failed turns", () => const runtimeError = events.find((event) => event.type === "runtime.error"); const turnCompleted = events.find((event) => event.type === "turn.completed"); - assert.equal(runtimeError?.type, "runtime.error"); - assert.deepEqual(turnCompleted?.payload, { + NodeAssert.equal(runtimeError?.type, "runtime.error"); + NodeAssert.deepEqual(turnCompleted?.payload, { state: "failed", errorMessage: "Droid stream failed", }); @@ -1073,11 +1074,11 @@ it.effect("marks aborted Droid turns as interrupted without runtime error", () = yield* adapter.interruptTurn(threadId); const events = yield* joinIterableFiber(eventsFiber); - assert.equal( + NodeAssert.equal( events.some((event) => event.type === "runtime.error"), false, ); - assert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { + NodeAssert.deepEqual(events.find((event) => event.type === "turn.completed")?.payload, { state: "interrupted", }); }), @@ -1100,7 +1101,7 @@ it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => const missing = yield* adapter .readThread(ThreadId.make("missing-droid-thread")) .pipe(Effect.exit); - assert.equal(missing._tag, "Failure"); + NodeAssert.equal(missing._tag, "Failure"); yield* adapter.startSession({ threadId, @@ -1122,17 +1123,17 @@ it.effect("reads Droid thread snapshots and rejects unsupported rollback", () => yield* Fiber.join(secondCompleted).pipe(Effect.timeout("2 seconds")); const before = yield* adapter.readThread(threadId); - assert.equal(before.turns.length, 2); + NodeAssert.equal(before.turns.length, 2); const rollback = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); - assert.equal(rollback._tag, "Failure"); + NodeAssert.equal(rollback._tag, "Failure"); if (rollback._tag === "Failure") { - assert.match(String(rollback.cause), /provider-native rewind\/fork support/); + NodeAssert.match(String(rollback.cause), /provider-native rewind\/fork support/); } const after = yield* adapter.readThread(threadId); - assert.equal(after.turns.length, 2); + NodeAssert.equal(after.turns.length, 2); const invalid = yield* adapter.rollbackThread(threadId, 0).pipe(Effect.exit); - assert.equal(invalid._tag, "Failure"); + NodeAssert.equal(invalid._tag, "Failure"); }), ).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/provider/Layers/DroidAdapter.ts b/apps/server/src/provider/Layers/DroidAdapter.ts index 07def79c4753..754c04cceaab 100644 --- a/apps/server/src/provider/Layers/DroidAdapter.ts +++ b/apps/server/src/provider/Layers/DroidAdapter.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { type AskUserRequestParams, type AskUserResult, @@ -148,7 +148,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter resolve(ToolConfirmationOutcome.Cancel); return; } - const requestId = ApprovalRequestId.make(`droid-${randomUUID()}`); + const requestId = ApprovalRequestId.make(`droid-${NodeCrypto.randomUUID()}`); const requestType = toRequestType(params); context.pendingPermissions.set(requestId, { requestType, resolve }); void emitNow({ @@ -169,7 +169,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter resolve({ cancelled: true, answers: [] }); return; } - const requestId = ApprovalRequestId.make(`droid-question-${randomUUID()}`); + const requestId = ApprovalRequestId.make(`droid-question-${NodeCrypto.randomUUID()}`); const questions = normalizeAskUserQuestions(params); context.pendingUserInputs.set(requestId, { questions, @@ -291,7 +291,7 @@ export function makeDroidAdapter(settings: DroidSettings, options?: DroidAdapter }); } - const turnId = TurnId.make(`droid-turn-${randomUUID()}`); + const turnId = TurnId.make(`droid-turn-${NodeCrypto.randomUUID()}`); const abort = new AbortController(); context.activeAbort = abort; context.activeAssistantItems = new Map(); diff --git a/apps/server/src/provider/Layers/DroidProvider.ts b/apps/server/src/provider/Layers/DroidProvider.ts index 413e9a8cac64..e4cc5515eb48 100644 --- a/apps/server/src/provider/Layers/DroidProvider.ts +++ b/apps/server/src/provider/Layers/DroidProvider.ts @@ -6,7 +6,7 @@ import { ModelProvider, ReasoningEffort, } from "@factory/droid-sdk"; -import { tmpdir } from "node:os"; +import * as NodeOS from "node:os"; import { type DroidSettings, ProviderDriverKind, @@ -193,7 +193,7 @@ export const discoverDroidModels = ( void (async () => { try { session = await (options?.sdk ?? defaultSdk).createSession({ - cwd: tmpdir(), + cwd: NodeOS.tmpdir(), execPath: settings.binaryPath, env: compactEnvironment(environment), abortSignal: abort.signal, @@ -280,6 +280,7 @@ export function checkDroidProviderStatus( const command = ChildProcess.make(settings.binaryPath, ["--version"], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probe is a pure process spawn outside the Effect runtime service graph. shell: process.platform === "win32", }); const result = yield* spawnAndCollect(settings.binaryPath, command).pipe( diff --git a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts index e17382ad2e66..6ea76d401ac4 100644 --- a/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { ApprovalRequestId, @@ -132,8 +133,8 @@ it.effect("delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "geminiCli"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "geminiCli"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }).pipe(Effect.scoped), ); @@ -148,9 +149,9 @@ it.effect("returns validation error when the provider is disabled", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") return; - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe( Effect.provide(makeAdapterLayer(new FakeGeminiCliManager(), disabledConfig)), Effect.scoped, @@ -168,11 +169,11 @@ it.effect("rejects attachments until Gemini CLI attachment wiring exists", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }).pipe(Effect.provide(makeAdapterLayer(new FakeGeminiCliManager())), Effect.scoped), ); @@ -202,14 +203,14 @@ it.effect("forwards manager runtime events through the adapter stream", () => const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/GeminiCliProvider.test.ts b/apps/server/src/provider/Layers/GeminiCliProvider.test.ts index 4d691bb4c53a..843e125fce66 100644 --- a/apps/server/src/provider/Layers/GeminiCliProvider.test.ts +++ b/apps/server/src/provider/Layers/GeminiCliProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import * as Schema from "effect/Schema"; import { describe, it } from "vite-plus/test"; @@ -20,11 +20,11 @@ describe("GeminiCliProvider capabilities", () => { const builtIn = draft.models.find((model) => !model.isCustom); if (!builtIn) { - assert.fail("expected a built-in gemini model"); + NodeAssert.fail("expected a built-in gemini model"); } const descriptors = builtIn.capabilities?.optionDescriptors ?? []; - assert.ok( + NodeAssert.ok( !descriptors.some((descriptor) => descriptor.id === "thinkingBudget"), "thinkingBudget was inert; keep it removed until wired to the Gemini CLI", ); diff --git a/apps/server/src/provider/Layers/GeminiCliProvider.ts b/apps/server/src/provider/Layers/GeminiCliProvider.ts index 79b19ea85bc4..e5bc24904969 100644 --- a/apps/server/src/provider/Layers/GeminiCliProvider.ts +++ b/apps/server/src/provider/Layers/GeminiCliProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * GeminiCliProvider — snapshot probe for the Gemini CLI provider. * @@ -91,6 +92,7 @@ const runGeminiCommand = Effect.fn("runGeminiCommand")(function* ( const binaryPath = resolveBinary(config); const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/KiloAdapter.test.ts b/apps/server/src/provider/Layers/KiloAdapter.test.ts index 1fe0f7c96942..395508a8fff9 100644 --- a/apps/server/src/provider/Layers/KiloAdapter.test.ts +++ b/apps/server/src/provider/Layers/KiloAdapter.test.ts @@ -1,4 +1,5 @@ -import assert from "node:assert/strict"; +// @effect-diagnostics globalDate:off globalDateInEffect:off - Tests build timestamped provider events. +import * as NodeAssert from "node:assert/strict"; import { EventId, @@ -91,8 +92,8 @@ it.effect("makeKiloAdapter delegates session startup to the manager", () => runtimeMode: "full-access", }); - assert.equal(session.provider, "kilo"); - assert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); + NodeAssert.equal(session.provider, "kilo"); + NodeAssert.equal(manager.startSessionImpl.mock.calls[0]?.[0], asThreadId("thread-1")); }), ), ); @@ -110,11 +111,11 @@ it.effect("makeKiloAdapter rejects attachments until Kilo wiring exists", () => }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), ), ); @@ -143,15 +144,15 @@ it.effect("makeKiloAdapter forwards manager runtime events through the stream", const received = yield* Stream.runHead(adapter.streamEvents); - assert.equal(received._tag, "Some"); + NodeAssert.equal(received._tag, "Some"); if (received._tag !== "Some") { return; } - assert.equal(received.value.type, "content.delta"); + NodeAssert.equal(received.value.type, "content.delta"); if (received.value.type !== "content.delta") { return; } - assert.equal(received.value.payload.delta, "hello"); + NodeAssert.equal(received.value.payload.delta, "hello"); }), ), ); @@ -169,11 +170,11 @@ it.effect("makeKiloAdapter rejects startSession when disabled", () => .startSession({ threadId: asThreadId("thread-disabled"), runtimeMode: "full-access" }) .pipe(Effect.result); - assert.equal(result._tag, "Failure"); + NodeAssert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { return; } - assert.equal(result.failure._tag, "ProviderAdapterValidationError"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterValidationError"); }), ), ); diff --git a/apps/server/src/provider/Layers/KiloProvider.test.ts b/apps/server/src/provider/Layers/KiloProvider.test.ts index 6edf9976766e..d39b6e080298 100644 --- a/apps/server/src/provider/Layers/KiloProvider.test.ts +++ b/apps/server/src/provider/Layers/KiloProvider.test.ts @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; @@ -19,21 +19,21 @@ describe("KiloProvider model discovery mapping", () => { { slug: "anthropic/claude", name: "Anthropic / Claude", connected: false }, ]); - assert.equal(result.length, 2); + NodeAssert.equal(result.length, 2); const [first, second] = result; if (!first || !second) { - assert.fail("expected two mapped models"); + NodeAssert.fail("expected two mapped models"); } - assert.equal(first.slug, "openai/gpt-5"); - assert.equal(first.name, "OpenAI / GPT-5"); - assert.equal(first.isCustom, false); - assert.ok(first.capabilities, "discovered models carry default capabilities"); - assert.equal(second.slug, "anthropic/claude"); - assert.equal(second.isCustom, false); + NodeAssert.equal(first.slug, "openai/gpt-5"); + NodeAssert.equal(first.name, "OpenAI / GPT-5"); + NodeAssert.equal(first.isCustom, false); + NodeAssert.ok(first.capabilities, "discovered models carry default capabilities"); + NodeAssert.equal(second.slug, "anthropic/claude"); + NodeAssert.equal(second.isCustom, false); }); it("returns an empty list when nothing is discovered", () => { - assert.deepEqual(kiloDiscoveredToServerModels([]), []); + NodeAssert.deepEqual(kiloDiscoveredToServerModels([]), []); }); }); diff --git a/apps/server/src/provider/Layers/KiloProvider.ts b/apps/server/src/provider/Layers/KiloProvider.ts index da8e023809b0..44fc41b04413 100644 --- a/apps/server/src/provider/Layers/KiloProvider.ts +++ b/apps/server/src/provider/Layers/KiloProvider.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics globalDate:off globalDateInEffect:off - Provider snapshot DTOs use ISO timestamps. /** * KiloProvider — snapshot probe for the Kilo Code provider. * @@ -113,6 +114,7 @@ const runKiloCommand = Effect.fn("runKiloCommand")(function* ( const binaryPath = kiloSettings.binaryPath.trim() || "kilo"; const command = ChildProcess.make(binaryPath, [...args], { env: environment, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Provider snapshot probes are pure process spawns outside the Effect runtime service graph. shell: process.platform === "win32", }); return yield* spawnAndCollect(binaryPath, command); diff --git a/apps/server/src/provider/Layers/copilotCliPath.ts b/apps/server/src/provider/Layers/copilotCliPath.ts index 489eb3b67945..a1eeb878d762 100644 --- a/apps/server/src/provider/Layers/copilotCliPath.ts +++ b/apps/server/src/provider/Layers/copilotCliPath.ts @@ -1,10 +1,11 @@ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const CURRENT_DIR = dirname(fileURLToPath(import.meta.url)); +// @effect-diagnostics nodeBuiltinImport:off - Pure Copilot CLI path resolver inspects packaged Node resources. +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import * as NodePath from "node:path"; +import * as NodeModule from "node:module"; + +const require = NodeModule.createRequire(import.meta.url); +const CURRENT_DIR = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const GITHUB_SCOPE_DIR = "@github"; const COPILOT_PATHLESS_COMMAND_PATTERN = /^copilot(?:\.(?:exe|cmd|bat))?$/i; const COPILOT_DESKTOP_ENV_BLOCKLIST = [ @@ -102,7 +103,7 @@ function resolveGithubScopeDirFromSdkEntrypoint( sdkEntrypoint: string | undefined, ): string | undefined { if (!sdkEntrypoint) return undefined; - return join(dirname(dirname(sdkEntrypoint)), ".."); + return NodePath.join(NodePath.dirname(NodePath.dirname(sdkEntrypoint)), ".."); } function resolveNodeModulesRoots(input: { @@ -112,13 +113,15 @@ function resolveNodeModulesRoots(input: { }): string[] { const githubScopeDir = resolveGithubScopeDirFromSdkEntrypoint(input.sdkEntrypoint); return dedupePaths([ - input.resourcesPath ? join(input.resourcesPath, "app.asar.unpacked/node_modules") : undefined, - input.resourcesPath ? join(input.resourcesPath, "node_modules") : undefined, - join(input.currentDir, "../../../../../app.asar.unpacked/node_modules"), - join(input.currentDir, "../../../../../../app.asar.unpacked/node_modules"), - join(input.currentDir, "../../../node_modules"), - join(input.currentDir, "../../../../../node_modules"), - githubScopeDir ? join(githubScopeDir, "..") : undefined, + input.resourcesPath + ? NodePath.join(input.resourcesPath, "app.asar.unpacked/node_modules") + : undefined, + input.resourcesPath ? NodePath.join(input.resourcesPath, "node_modules") : undefined, + NodePath.join(input.currentDir, "../../../../../app.asar.unpacked/node_modules"), + NodePath.join(input.currentDir, "../../../../../../app.asar.unpacked/node_modules"), + NodePath.join(input.currentDir, "../../../node_modules"), + NodePath.join(input.currentDir, "../../../../../node_modules"), + githubScopeDir ? NodePath.join(githubScopeDir, "..") : undefined, ]); } @@ -127,7 +130,9 @@ function getCopilotPlatformBinaryName(platform: string): string { } export function getBundledCopilotPlatformPackages( + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps platform injectable for tests and non-Effect callers. platform: string = process.platform, + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps architecture injectable for tests and non-Effect callers. arch: string = process.arch, ): ReadonlyArray { if (platform === "darwin" && arch === "arm64") { @@ -160,9 +165,11 @@ export function resolveBundledCopilotCliPathFrom(input: { arch?: string; exists?: (path: string) => boolean; }): string | undefined { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps platform injectable for tests and non-Effect callers. const platform = input.platform ?? process.platform; + // oxlint-disable-next-line t3code/no-global-process-runtime -- Pure resolver keeps architecture injectable for tests and non-Effect callers. const arch = input.arch ?? process.arch; - const exists = input.exists ?? existsSync; + const exists = input.exists ?? NodeFS.existsSync; const sdkEntrypoint = input.sdkEntrypoint; const nodeModulesRoots = resolveNodeModulesRoots({ currentDir: input.currentDir, @@ -173,7 +180,9 @@ export function resolveBundledCopilotCliPathFrom(input: { const platformPackages = getBundledCopilotPlatformPackages(platform, arch); const binaryCandidates = nodeModulesRoots.flatMap((root) => - platformPackages.map((packageName) => join(root, GITHUB_SCOPE_DIR, packageName, binaryName)), + platformPackages.map((packageName) => + NodePath.join(root, GITHUB_SCOPE_DIR, packageName, binaryName), + ), ); for (const candidate of dedupePaths(binaryCandidates)) { if (exists(candidate)) { @@ -187,7 +196,7 @@ export function resolveBundledCopilotCliPathFrom(input: { } const sdkSiblingBinaryCandidates = platformPackages.map((packageName) => - join(githubScopeDir, packageName, binaryName), + NodePath.join(githubScopeDir, packageName, binaryName), ); for (const candidate of dedupePaths(sdkSiblingBinaryCandidates)) { if (exists(candidate)) { diff --git a/apps/server/src/provider/droid/DroidRuntimeEvents.ts b/apps/server/src/provider/droid/DroidRuntimeEvents.ts index fb9f32358fb9..e35caee5cd42 100644 --- a/apps/server/src/provider/droid/DroidRuntimeEvents.ts +++ b/apps/server/src/provider/droid/DroidRuntimeEvents.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import { DroidMessageType, type DroidMessage } from "@factory/droid-sdk"; import { EventId, @@ -37,7 +37,7 @@ export function makeDroidEventBase(instanceId: ProviderInstanceId) { raw?: unknown; }, ) => ({ - eventId: EventId.make(randomUUID()), + eventId: EventId.make(NodeCrypto.randomUUID()), provider: DROID_PROVIDER, providerInstanceId: instanceId, threadId: context.session.threadId, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fd08d2959071..7e172079bf05 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - VCS driver uses Node path helpers at the process boundary. import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; import * as Data from "effect/Data"; @@ -18,7 +19,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import nodePath from "node:path"; +import * as NodePath from "node:path"; import { GitCommandError, @@ -770,7 +771,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* kind: "client", attributes: { "git.operation": input.operation, - "git.repo": nodePath.basename(input.cwd), + "git.repo": NodePath.basename(input.cwd), "git.args_count": input.args.length, }, }), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 0178735666e1..8ed5238bd6b9 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,4 +1,4 @@ -import { createRequire } from "node:module"; +import * as NodeModule from "node:module"; import "vite-plus/test/config"; import { defineConfig, mergeConfig } from "vite-plus"; @@ -17,7 +17,7 @@ const bundledPackagePrefixes = [ "@opencode-ai/", ]; -const require = createRequire(import.meta.url); +const require = NodeModule.createRequire(import.meta.url); // @github/copilot-sdk ships an ESM build that imports "vscode-jsonrpc/node" // without the `.js` extension. Under Node's nodenext resolver this throws diff --git a/scripts/lib/macos-icon-composer.ts b/scripts/lib/macos-icon-composer.ts index 668ca14e7b18..2740c9138aae 100644 --- a/scripts/lib/macos-icon-composer.ts +++ b/scripts/lib/macos-icon-composer.ts @@ -1,7 +1,8 @@ -import { spawnSync } from "node:child_process"; -import { cp, mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +// @effect-diagnostics nodeBuiltinImport:off - Standalone icon asset compiler shells out to actool and reads generated files. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; export interface CompiledMacIconAsset { readonly assetCatalog: Buffer; @@ -14,7 +15,7 @@ function parseActoolVersion(rawOutput: string): string | null { } function assertSupportedActoolVersion(): void { - const result = spawnSync("actool", ["--version"], { + const result = NodeChildProcess.spawnSync("actool", ["--version"], { encoding: "utf8", }); const version = parseActoolVersion(`${result.stdout ?? ""}${result.stderr ?? ""}`); @@ -38,15 +39,17 @@ export async function generateAssetCatalogForIcon( ): Promise { assertSupportedActoolVersion(); - const tempRoot = await mkdtemp(resolve(tmpdir(), "t3code-icon-composer-")); - const iconPath = resolve(tempRoot, "Icon.icon"); - const outputPath = resolve(tempRoot, "out"); + const tempRoot = await NodeFSP.mkdtemp( + NodePath.resolve(NodeOS.tmpdir(), "t3code-icon-composer-"), + ); + const iconPath = NodePath.resolve(tempRoot, "Icon.icon"); + const outputPath = NodePath.resolve(tempRoot, "out"); try { - await cp(inputPath, iconPath, { recursive: true }); - await mkdir(outputPath, { recursive: true }); + await NodeFSP.cp(inputPath, iconPath, { recursive: true }); + await NodeFSP.mkdir(outputPath, { recursive: true }); - const result = spawnSync( + const result = NodeChildProcess.spawnSync( "actool", [ iconPath, @@ -57,7 +60,7 @@ export async function generateAssetCatalogForIcon( "--notices", "--warnings", "--output-partial-info-plist", - resolve(outputPath, "assetcatalog_generated_info.plist"), + NodePath.resolve(outputPath, "assetcatalog_generated_info.plist"), "--app-icon", "Icon", "--include-all-app-icons", @@ -86,10 +89,10 @@ export async function generateAssetCatalogForIcon( } return { - assetCatalog: await readFile(resolve(outputPath, "Assets.car")), - icnsFile: await readFile(resolve(outputPath, "Icon.icns")), + assetCatalog: await NodeFSP.readFile(NodePath.resolve(outputPath, "Assets.car")), + icnsFile: await NodeFSP.readFile(NodePath.resolve(outputPath, "Icon.icns")), }; } finally { - await rm(tempRoot, { recursive: true, force: true }); + await NodeFSP.rm(tempRoot, { recursive: true, force: true }); } } diff --git a/scripts/sync-upstream-pr-tracks.mjs b/scripts/sync-upstream-pr-tracks.mjs index 0a75b8ad9add..a5da4308ac3a 100644 --- a/scripts/sync-upstream-pr-tracks.mjs +++ b/scripts/sync-upstream-pr-tracks.mjs @@ -1,14 +1,14 @@ #!/usr/bin/env node -import fs from "node:fs"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; -const repoRoot = path.resolve(import.meta.dirname, ".."); -const configPath = path.join(repoRoot, "config", "upstream-pr-tracks.json"); +const repoRoot = NodePath.resolve(import.meta.dirname, ".."); +const configPath = NodePath.join(repoRoot, "config", "upstream-pr-tracks.json"); function runGit(args, options = {}) { - const output = execFileSync("git", args, { + const output = NodeChildProcess.execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], @@ -48,7 +48,7 @@ function deriveRepoUrl(remoteName) { } function loadConfig() { - const raw = fs.readFileSync(configPath, "utf8"); + const raw = NodeFS.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { throw new Error("Invalid upstream PR tracking config."); From b8c78bbed8f42af2ce7beca7216df92bbac9a3a8 Mon Sep 17 00:00:00 2001 From: aaditagrawal Date: Wed, 24 Jun 2026 13:28:05 +0530 Subject: [PATCH 66/66] Fix structured errors runtime test imports --- .../Layers/ProviderSessionDirectory.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1fac14f18149..0b33b346a133 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -16,15 +16,12 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; -import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; +import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; function makeDirectoryLayer(persistenceLayer: Layer.Layer) { - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), @@ -36,7 +33,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const initialThreadId = ThreadId.make("thread-1"); @@ -83,7 +80,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-runtime"); @@ -128,7 +125,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const olderThreadId = ThreadId.make("thread-runtime-older"); const newerThreadId = ThreadId.make("thread-runtime-newer"); @@ -202,7 +199,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const threadId = ThreadId.make("thread-provider-change"); yield* runtimeRepository.upsert({