From ed57bed8e71b2e89a9350c94bda0dacc71cd4817 Mon Sep 17 00:00:00 2001 From: Morgana Allen Date: Mon, 28 Sep 2026 11:10:54 -0700 Subject: [PATCH 01/16] fix(web): skip unchanged shortcut modifier updates - 195x typing latency improvement (#13884) Co-authored-by: GPT-6 Astra --- apps/web/src/shortcutModifierState.test.ts | 68 +++++++++++++++++++++- apps/web/src/shortcutModifierState.ts | 18 +++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts index 4ef4b3b6bf2d..20b63220072d 100644 --- a/apps/web/src/shortcutModifierState.test.ts +++ b/apps/web/src/shortcutModifierState.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, it } from "vite-plus/test"; +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { areShortcutModifierStatesEqual, shortcutModifierStateAfterKeyboardEvent, + useShortcutModifierState, type ShortcutModifierState, } from "./shortcutModifierState"; @@ -13,6 +18,67 @@ const emptyState = (): ShortcutModifierState => ({ shiftKey: false, }); +describe("useShortcutModifierState", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it.each(["keyup", "paste", "blur"] as const)( + "does not render for unchanged modifiers after %s resets the state", + async (reset) => { + const render = vi.fn(); + function Consumer() { + render(useShortcutModifierState()); + return null; + } + await act(async () => { + root.render(createElement(Consumer)); + }); + const press = async (type: "keydown" | "keyup", key: string, shiftKey = false) => { + await act(async () => { + window.dispatchEvent(new KeyboardEvent(type, { key, shiftKey })); + }); + }; + + await press("keydown", "Shift", true); + expect(render).toHaveBeenLastCalledWith({ ...emptyState(), shiftKey: true }); + if (reset === "keyup") { + await press("keyup", "Shift"); + } else { + await act(async () => { + window.dispatchEvent(new Event(reset)); + }); + } + expect(render).toHaveBeenLastCalledWith(emptyState()); + render.mockClear(); + + for (const key of "typing") { + await press("keydown", key); + await press("keyup", key); + } + await act(async () => { + window.dispatchEvent(new Event("paste")); + window.dispatchEvent(new Event("blur")); + }); + expect(render).not.toHaveBeenCalled(); + await press("keydown", "Shift", true); + expect(render).toHaveBeenCalledExactlyOnceWith({ ...emptyState(), shiftKey: true }); + }, + ); +}); + function keyboardEventLike(type: "keydown" | "keyup", init: Partial): KeyboardEvent { return { type, diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts index 3abeeaa3e8aa..15d6e0a1bcae 100644 --- a/apps/web/src/shortcutModifierState.ts +++ b/apps/web/src/shortcutModifierState.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; export interface ShortcutModifierState { metaKey: boolean; @@ -28,10 +28,18 @@ export function areShortcutModifierStatesEqual( export function useShortcutModifierState(): ShortcutModifierState { const [state, setState] = useState(EMPTY_SHORTCUT_MODIFIER_STATE); + const stateRef = useRef(EMPTY_SHORTCUT_MODIFIER_STATE); useEffect(() => { + const updateState = (next: ShortcutModifierState) => { + // Even a no-op state dispatch can cost work in the sidebar's large tree. + // Ordinary typing must return before dispatching a React update. + if (areShortcutModifierStatesEqual(stateRef.current, next)) return; + stateRef.current = next; + setState(next); + }; const onKeyboardEvent = (event: KeyboardEvent) => { - setState((current) => shortcutModifierStateAfterKeyboardEvent(current, event)); + updateState(shortcutModifierStateAfterKeyboardEvent(stateRef.current, event)); }; // Dictation tools (Wispr Flow) paste with a synthetic ⌘V whose Meta keyup // never reaches the page, so the tracked state stays "⌘ held" forever and @@ -39,11 +47,7 @@ export function useShortcutModifierState(): ShortcutModifierState { // treat it like a blur and reset. A physically held modifier re-registers // on the next real key event. const onResetEvent = () => { - setState((current) => - areShortcutModifierStatesEqual(current, EMPTY_SHORTCUT_MODIFIER_STATE) - ? current - : EMPTY_SHORTCUT_MODIFIER_STATE, - ); + updateState(EMPTY_SHORTCUT_MODIFIER_STATE); }; window.addEventListener("keydown", onKeyboardEvent, true); From ba79610d166dcc7a9b8beb013d18304bc6261307 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 13:02:08 -0700 Subject: [PATCH 02/16] fix(web,mobile): say that removing a T3 Connect environment keeps its account registration (#14127) Co-authored-by: Claude Opus 5.5 (1M context) --- .../state/use-remote-environment-registry.ts | 39 +++++++--- .../RemoveT3ConnectEnvironmentDialog.tsx | 78 +++++++++++++++++++ .../clerk/T3ConnectAccountPages.tsx | 66 ++++++++++++++++ .../clerk/T3ConnectSidebarSignIn.tsx | 29 +++---- .../settings/ConnectionsSettings.tsx | 46 ++++++++--- docs/user/remote-access.md | 3 +- 6 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx create mode 100644 apps/web/src/components/clerk/T3ConnectAccountPages.tsx diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 1664dc7461aa..ce309f5b06fd 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() { export function useRemoteConnections() { const controller = useConnectionController(); + const navigation = useNavigation(); const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); @@ -171,22 +173,37 @@ export function useRemoteConnections() { if (!environment) { return; } + const remove = { + text: "Remove", + style: "destructive", + onPress: () => { + void controller.removeEnvironment(environmentId); + }, + } as const; + // Removing a T3 Connect environment here leaves its account registration + // and host space, so point to where it can be deregistered. + if (environment.isRelayManaged) { + Alert.alert( + "Remove from this device?", + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), + }, + remove, + ], + ); + return; + } Alert.alert( "Remove from this device?", `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => { - void controller.removeEnvironment(environmentId); - }, - }, - ], + [{ text: "Cancel", style: "cancel" }, remove], ); }, - [connectedEnvironments, controller], + [connectedEnvironments, controller, navigation], ); return { diff --git a/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx new file mode 100644 index 000000000000..5e6d6e00cba7 --- /dev/null +++ b/apps/web/src/components/clerk/RemoveT3ConnectEnvironmentDialog.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button, InlineButton } from "../ui/button"; +import { useT3ConnectAccountPage } from "./T3ConnectAccountPages"; + +/** + * Confirms removing a T3 Connect environment from this device. Removal here + * leaves the account registration (and its host space) in place, so the dialog + * says so and links to the account page where it can be deregistered. + */ +export function RemoveT3ConnectEnvironmentDialog({ + environmentLabel, + onCancel, + onConfirm, +}: { + /** The environment awaiting confirmation; null keeps the dialog closed. */ + readonly environmentLabel: string | null; + readonly onCancel: () => void; + readonly onConfirm: () => void; +}) { + const accountPage = useT3ConnectAccountPage(); + // Keep the label through the close animation. + const [shownLabel, setShownLabel] = useState(environmentLabel); + if (environmentLabel !== null && environmentLabel !== shownLabel) setShownLabel(environmentLabel); + const openAccountPage = accountPage.open; + + return ( + <> + { + if (!open) onCancel(); + }} + > + + + Remove {shownLabel} from this device? + + This forgets its pairing, credentials, and cached threads here. + + + It stays on your T3 Connect account and keeps its host space. Deregister it in{" "} + {openAccountPage ? ( + { + onCancel(); + openAccountPage(); + }} + > + T3 Connect settings + + ) : ( + "T3 Connect settings" + )}{" "} + to free it. + + + + }>Cancel + + + + + {accountPage.portals} + + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx new file mode 100644 index 000000000000..44b423493a18 --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx @@ -0,0 +1,66 @@ +import { useAuth, useClerk } from "@clerk/react"; +import { ServerIcon, SmartphoneIcon } from "lucide-react"; +import { type ReactNode, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; + +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; +import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; + +/** Custom pages in the Clerk account modal, in menu order. */ +export const T3_CONNECT_ACCOUNT_PAGES = [ + { + label: "Mobile clients", + url: "mobile-clients", + icon: , + content: , + }, + { + label: "T3 Connect", + url: "t3-connect", + icon: , + content: , + }, +] as const; + +type PortalTargets = Readonly>; + +/** + * Opens the Clerk account modal on the T3 Connect page from outside the + * UserButton. Clerk mounts custom pages into DOM nodes it owns, so the caller + * must keep `portals` rendered for as long as the modal can be open. + */ +export function useT3ConnectAccountPage(): { + readonly open: (() => void) | null; + readonly portals: ReactNode; +} { + const clerk = useClerk(); + const { isSignedIn } = useAuth(); + const [targets, setTargets] = useState({}); + + const open = useCallback(() => { + const setTarget = (key: string, element: HTMLDivElement | undefined) => + setTargets((current) => ({ ...current, [key]: element })); + clerk.openUserProfile({ + __experimental_startPath: "/t3-connect", + customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({ + label: page.label, + url: page.url, + mount: (element: HTMLDivElement) => setTarget(`content:${page.url}`, element), + unmount: () => setTarget(`content:${page.url}`, undefined), + mountIcon: (element: HTMLDivElement) => setTarget(`icon:${page.url}`, element), + unmountIcon: () => setTarget(`icon:${page.url}`, undefined), + })), + }); + }, [clerk]); + + const portals = T3_CONNECT_ACCOUNT_PAGES.flatMap((page) => { + const content = targets[`content:${page.url}`]; + const icon = targets[`icon:${page.url}`]; + return [ + content ? createPortal(page.content, content, `content:${page.url}`) : null, + icon ? createPortal(page.icon, icon, `icon:${page.url}`) : null, + ]; + }); + + return { open: isSignedIn ? open : null, portals }; +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index 9dfd8dce13b1..e75ce33ceaf3 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,10 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; +import { LogInIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; -import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; -import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; +import { T3_CONNECT_ACCOUNT_PAGES } from "./T3ConnectAccountPages"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -33,20 +32,16 @@ function ConfiguredT3ConnectSidebarAvatar() { }, }} > - } - url="mobile-clients" - > - - - } - url="t3-connect" - > - - + {T3_CONNECT_ACCOUNT_PAGES.map((page) => ( + + {page.content} + + ))} ); } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index cf37a4167c4d..8e27f4193d74 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -143,6 +143,7 @@ import { supportsServerUpdateThreadContinuation, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { RemoveT3ConnectEnvironmentDialog } from "../clerk/RemoveT3ConnectEnvironmentDialog"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -2514,18 +2515,8 @@ export function ConnectionsSettings() { [setEnvironmentEnabled], ); - // Removing forgets the pairing, credentials, and cached threads on this - // device. Switching off is the reversible path, so removal always confirms. - const handleRemoveSavedBackend = useCallback( + const removeSavedBackend = useCallback( async (environment: EnvironmentPresentation) => { - // Fail closed: no mounted confirm host means no removal. - const confirmed = await requestConfirmDialog( - `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`, - { variant: "destructive" }, - ); - if (confirmed !== true) { - return; - } const environmentId = environment.environmentId; setRemovingSavedEnvironmentId(environmentId); setSavedBackendError(null); @@ -2547,6 +2538,28 @@ export function ConnectionsSettings() { [removeEnvironment], ); + // Removing forgets the pairing, credentials, and cached threads on this + // device. Switching off is the reversible path, so removal always confirms. + // T3 Connect environments get their own dialog: removing one here leaves its + // account registration, so it points to where that can be deregistered. + const [pendingT3ConnectRemoval, setPendingT3ConnectRemoval] = + useState(null); + const handleRemoveSavedBackend = useCallback( + async (environment: EnvironmentPresentation) => { + if (environment.relayManaged && hasCloudPublicConfig()) { + setPendingT3ConnectRemoval(environment); + return; + } + // Fail closed: no mounted confirm host means no removal. + const confirmed = await requestConfirmDialog( + `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`, + { variant: "destructive" }, + ); + if (confirmed === true) await removeSavedBackend(environment); + }, + [removeSavedBackend], + ); + const visibleDesktopPairingLinks = desktopPairingLinks; const tailscaleHttpsEndpoint = useMemo( () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, @@ -3762,6 +3775,17 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + {hasCloudPublicConfig() ? ( + setPendingT3ConnectRemoval(null)} + onConfirm={() => { + if (!pendingT3ConnectRemoval) return; + setPendingT3ConnectRemoval(null); + void removeSavedBackend(pendingT3ConnectRemoval); + }} + /> + ) : null} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index bac23103f0bf..f3fb96b830f9 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -156,7 +156,8 @@ expires. To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is -offline or has been wiped. +offline or has been wiped. Removing an environment from a device's connection +settings only forgets it on that device; it stays registered to your account. When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's tunnel after it stays offline for several minutes. The environment stays linked From 72330e22c0d08159641ab10579eb75393c4d95b6 Mon Sep 17 00:00:00 2001 From: Artur Date: Mon, 28 Sep 2026 23:33:08 +0200 Subject: [PATCH 03/16] feat(models): add Claude Sonnet 5.5 (#14152) Adds claude-sonnet-5-5 to the Claude catalog (reuses the sonnet-5 profile, requires Claude Code 2.1.284) and features it in currentModels. Sonnet 5 stays current. --- apps/server/src/provider/model-manifest.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index b7531c21c981..87efe519ea1c 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-24T18:40:00Z", + "updatedAt": "2026-09-28T20:00:00Z", "compatibility": [ { "driver": "codex", @@ -64,7 +64,7 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-sonnet-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { @@ -659,6 +659,15 @@ "profile": "opus-5-5", "adapter": { "claudeCode": { "minVersion": "2.1.280" } } }, + { + "slug": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "aliases": ["sonnet-5.5", "claude-sonnet-5.5"], + "status": "current", + "badge": "new", + "profile": "sonnet-5", + "adapter": { "claudeCode": { "minVersion": "2.1.284" } } + }, { "slug": "claude-fable-5-1", "name": "Claude Fable 5.1", From b528a701102f95089544596856a3f208f4404613 Mon Sep 17 00:00:00 2001 From: SegFaultZero Date: Tue, 29 Sep 2026 06:19:27 +0800 Subject: [PATCH 04/16] fix(server): restore Windows terminal startup after node-pty upgrade (#13927) Co-authored-by: UtkarshUsername Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/terminal/Manager.test.ts | 34 +++ apps/server/src/terminal/Manager.ts | 31 +-- .../src/terminal/NodePtyAdapter.test.ts | 228 +++++++++++++++++- apps/server/src/terminal/NodePtyAdapter.ts | 137 ++++++++++- 4 files changed, 396 insertions(+), 34 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f04161cfbd3c..325233bf59d9 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -56,6 +56,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; constructor(pid: number) { this.pid = pid; @@ -88,6 +89,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + if (this.exitOnSubscribe) callback(this.exitOnSubscribe); this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -113,6 +115,7 @@ class FakePtyAdapter { readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; private nextPid = 9000; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; @@ -133,6 +136,7 @@ class FakePtyAdapter { ); } const process = new FakePtyProcess(this.nextPid++); + process.exitOnSubscribe = this.exitOnSubscribe; this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ @@ -628,6 +632,36 @@ it.layer( }), ); + it.effect("handles an exit replayed during subscription after publishing startup", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + ptyAdapter.exitOnSubscribe = { exitCode: 7, signal: null }; + const { manager, getEvents } = yield* createManager(5, { ptyAdapter }); + const exited = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "exited" + ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.open(openInput()); + yield* Deferred.await(exited); + const events = yield* getEvents; + expect(events.map((event) => event.type)).toEqual(["started", "exited"]); + expect(events[1]).toMatchObject({ exitCode: 7 }); + const attached: TerminalAttachStreamEvent[] = []; + const stopAttach = yield* manager.attachStream(openInput(), (event) => + Effect.sync(() => { + attached.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopAttach)); + expect(attached.find((event) => event.type === "snapshot")).toMatchObject({ + snapshot: { status: "exited", exitCode: 7 }, + }); + }), + ); + it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 75d592c00c0d..43d6a0750e65 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -2243,18 +2243,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func startedShell = spawnResult.shellLabel; const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); + let eventsActivated = false; let eventStamp: ReturnType = { updatedAt: session.updatedAt, @@ -2264,8 +2253,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = ptyProcess; session.pid = processPid; session.status = "running"; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; + // onExit may replay an exit immediately; accept it before subscribing. + session.unsubscribeData = spawnResult.process.onData((data) => { + if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + return; + } + if (eventsActivated) runFork(drainProcessEvents(session, processPid)); + }); + session.unsubscribeExit = spawnResult.process.onExit((event) => { + if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + return; + } + if (eventsActivated) runFork(drainProcessEvents(session, processPid)); + }); eventStamp = advanceEventSequence(session); return [undefined, state] as const; }); @@ -2277,6 +2277,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: eventStamp.sequence, snapshot: snapshot(session), }); + // Publish startup before draining any events replayed during subscription. + eventsActivated = true; + if (session.processEventDrainRunning) runFork(drainProcessEvents(session, processPid)); }), ), ), diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index e6650025f70f..8107e19e6165 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -1,23 +1,63 @@ +import * as NodeEvents from "node:events"; +import * as NodeNet from "node:net"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; +import * as Logger from "effect/Logger"; +import * as Scheduler from "effect/Scheduler"; +import { expect, vi } from "vite-plus/test"; import * as NodePtyAdapter from "./NodePtyAdapter.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; -const spawn = vi.fn(() => ({ - pid: 42, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn(() => ({ dispose: vi.fn() })), -})); +function makeNativeProcess(pid = 42) { + const events = new NodeEvents.EventEmitter(); + return { + pid, + _socket: new NodeNet.Socket(), + _agent: { kill: vi.fn() }, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn((callback: (data: string) => void) => { + events.on("data", callback); + return { + dispose: () => { + events.off("data", callback); + }, + }; + }), + onExit: vi.fn((callback: (event: { exitCode: number; signal?: number }) => void) => { + events.on("exit", callback); + return { + dispose: () => { + events.off("exit", callback); + }, + }; + }), + events, + }; +} + +const spawn = vi.fn(() => makeNativeProcess()); + +function preparePendingProcess() { + const nativeProcess = makeNativeProcess(0); + const subscribed = Promise.withResolvers(); + nativeProcess._socket.on("newListener", (event) => { + if (event === "ready_datapipe") queueMicrotask(() => subscribed.resolve()); + }); + spawn.mockReturnValueOnce(nativeProcess); + return { nativeProcess, subscribed: Effect.promise(() => subscribed.promise) }; +} + +const spawnInput = { shell: "powershell.exe", cwd: ".", cols: 80, rows: 24, env: {} }; const fakeNodePty = { spawn } as unknown as typeof import("node-pty"); @@ -35,6 +75,91 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") => const testLayer = makeTestLayer(); +it.effect("waits for the Windows PID without requiring output", () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + let completed = false; + const fiber = yield* adapter.spawn(spawnInput).pipe( + Effect.tap(() => + Effect.sync(() => { + completed = true; + }), + ), + Effect.forkChild, + ); + yield* subscribed; + assert.isFalse(completed); + nativeProcess.pid = 12345; + nativeProcess._socket.emit("ready_datapipe"); + const process = yield* Fiber.join(fiber); + assert.equal(process.pid, 12345); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 1); + + const output: string[] = []; + const exits: PtyAdapter.PtyExitEvent[] = []; + const stopData = process.onData((data) => output.push(data)); + const stopExit = process.onExit((event) => exits.push(event)); + nativeProcess.events.emit("data", "first output"); + nativeProcess.events.emit("exit", { exitCode: 0 }); + assert.deepEqual(output, ["first output"]); + assert.deepEqual(exits, [{ exitCode: 0, signal: null }]); + stopData(); + stopExit(); + }).pipe(Effect.provide(testLayer)), +); + +for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { + it.effect(`fails Windows startup on ${failure} and cleans up`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.result, Effect.forkChild); + yield* subscribed; + if (failure === "exit") nativeProcess.events.emit("exit", { exitCode: 1 }); + else if (failure === "error") nativeProcess._socket.emit("error", new Error("pipe failed")); + else nativeProcess._socket.emit(failure === "close" ? "close" : "ready_datapipe"); + const result = yield* Fiber.join(fiber); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.instanceOf(result.failure, PtyAdapter.PtySpawnError); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess._socket.listenerCount("error"), 0); + assert.equal(nativeProcess._socket.listenerCount("close"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + }).pipe(Effect.provide(testLayer)), + ); +} + +it.effect("cancels the Windows connection without waiting for output", () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.forkChild); + yield* subscribed; + yield* Fiber.interrupt(fiber); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + assert.equal(nativeProcess.kill.mock.calls.length, 0); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("reports an incompatible Windows readiness API instead of hanging", () => + Effect.gen(function* () { + const nativeProcess = makeNativeProcess(0); + Reflect.deleteProperty(nativeProcess, "_socket"); + spawn.mockReturnValueOnce(nativeProcess); + const adapter = yield* PtyAdapter.PtyAdapter; + const error = yield* adapter.spawn(spawnInput).pipe(Effect.flip); + assert.instanceOf(error, PtyAdapter.PtySpawnError); + assert.instanceOf(error.cause, Error); + assert.equal(error.cause.message, "Windows PTY readiness socket is unavailable."); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + }).pipe(Effect.provide(testLayer)), +); + for (const platform of ["win32", "linux", "darwin"] as const) { it.effect(`terminates through node-pty using ${platform} semantics`, () => Effect.gen(function* () { @@ -153,3 +278,88 @@ it.effect("reports native module load failures as structured startup defects", ( ), ), ); + +for (const budget of [2048, 8]) { + it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const exits: PtyAdapter.PtyExitEvent[] = []; + const fiber = yield* Effect.gen(function* () { + const process = yield* adapter.spawn(spawnInput); + process.onExit((event) => exits.push(event)); + }).pipe( + Effect.provideService(Scheduler.MaxOpsBeforeYield, budget), + Effect.provideService(Scheduler.PreventSchedulerYield, false), + Effect.forkChild, + ); + yield* subscribed; + nativeProcess.pid = 12345; + nativeProcess._socket.emit("ready_datapipe"); + nativeProcess.events.emit("exit", { exitCode: 0 }); + yield* Fiber.join(fiber); + assert.equal(exits.length, 1); + }).pipe(Effect.provide(testLayer)), + ); +} + +it.effect("replays an exit to late subscribers and respects unsubscription", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn(spawnInput); + const nativeProcess = spawn.mock.results.at(-1)!.value; + const removed = vi.fn(); + process.onExit(removed)(); + nativeProcess.events.emit("exit", { exitCode: 7, signal: 2 }); + const late = vi.fn(); + process.onExit(late); + nativeProcess.events.emit("exit", { exitCode: 9 }); + assert.equal(removed.mock.calls.length, 0); + assert.deepEqual(late.mock.calls, [[{ exitCode: 7, signal: 2 }]]); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(testLayer)), +); + +for (const failure of ["spawn", "interrupt"] as const) { + it.effect(`logs cleanup failures without replacing ${failure}`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const killError = new Error("native kill failed"); + nativeProcess._agent.kill.mockImplementation(() => { + throw killError; + }); + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter + .spawn(spawnInput) + .pipe( + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.forkChild, + ); + yield* subscribed; + const spawnError = new Error("pipe failed"); + if (failure === "interrupt") yield* Fiber.interrupt(fiber); + else nativeProcess._socket.emit("error", spawnError); + const exit = yield* Fiber.await(fiber); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + if (failure === "interrupt") assert.isTrue(Cause.hasInterrupts(exit.cause)); + else { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, PtyAdapter.PtySpawnError); + assert.equal(error.cause, spawnError); + } + } + assert.equal(messages.length, 1); + expect(messages[0]).toMatchObject([ + "failed to cancel Windows terminal startup", + { terminalPid: 0, cause: { cause: killError } }, + ]); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + }).pipe(Effect.provide(testLayer)), + ); +} diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 67cdcecdd53a..8ba78ee4d288 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,4 +1,5 @@ import * as NodeModule from "node:module"; +import * as NodeNet from "node:net"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -82,13 +83,112 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); +/** + * Waits for Windows process creation so the manager receives a valid PID. + * node-pty defers creation to avoid blocking on named pipes: + * https://github.com/microsoft/node-pty/pull/885 + * T3 adopted that behavior when upgrading from 1.1.0 to 1.2.0-beta.15: + * https://github.com/pingdotgg/t3code/pull/13748 + * Its public API has no readiness event. The private ready_datapipe handler sets + * pid before our listener runs. + */ +const waitForWindowsPid = ( + process: import("node-pty").IPty, + trackedProcess: NodePtyProcess, + shell: string, +) => + Effect.callback((resume) => { + const hasPid = () => Number.isInteger(process.pid) && process.pid > 0; + const failure = (cause: unknown) => + Effect.fail(new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell, cause })); + + if (hasPid()) { + resume(Effect.void); + return; + } + + if (!("_socket" in process) || !(process._socket instanceof NodeNet.Socket)) { + resume(failure(new Error("Windows PTY readiness socket is unavailable."))); + return; + } + + const socket = process._socket; + const onReady = () => { + cleanup(); + resume( + hasPid() + ? Effect.void + : failure(new Error("Windows PTY became ready without a valid PID.")), + ); + }; + const onError = (cause: Error) => { + cleanup(); + resume(failure(cause)); + }; + const onClose = () => onError(new Error("Windows PTY closed before its PID was available.")); + let stopExit = () => {}; + const cleanup = () => { + socket.off("ready_datapipe", onReady); + socket.off("error", onError); + socket.off("close", onClose); + stopExit(); + }; + socket.once("ready_datapipe", onReady); + socket.once("error", onError); + socket.once("close", onClose); + stopExit = trackedProcess.onExit(({ exitCode }) => + onError( + new Error(`Windows PTY exited before its PID was available (exit code ${exitCode}).`), + ), + ); + return Effect.sync(cleanup); + }); + +/** + * Cancels Windows startup without waiting for the first output, unlike public kill(). + * The private agent can cancel the pending connection before a child exists. + * Cleanup failures are logged without replacing the startup failure. + */ +const killStartingWindowsPty = (process: import("node-pty").IPty) => + Effect.try(() => { + if ( + "_agent" in process && + typeof process._agent === "object" && + process._agent !== null && + "kill" in process._agent && + typeof process._agent.kill === "function" + ) { + process._agent.kill(); + } else { + process.kill(); + } + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to cancel Windows terminal startup", { + terminalPid: process.pid, + cause: error, + }), + ), + ); + class NodePtyProcess implements PtyAdapter.PtyProcess { private readonly process: import("node-pty").IPty; private readonly platform: NodeJS.Platform; + private exitEvent: PtyAdapter.PtyExitEvent | undefined; + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private readonly exitSubscription: import("node-pty").IDisposable; constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) { this.process = process; this.platform = platform; + // Retain exits while Windows readiness and the manager hand off the process. + this.exitSubscription = process.onExit((event) => { + if (this.exitEvent) return; + this.exitEvent = { exitCode: event.exitCode, signal: event.signal ?? null }; + this.exitSubscription.dispose(); + for (const listener of this.exitListeners) listener(this.exitEvent); + this.exitListeners.clear(); + }); } get pid(): number { @@ -116,16 +216,20 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); + if (this.exitEvent) { + callback(this.exitEvent); + return () => {}; + } + this.exitListeners.add(callback); return () => { - disposable.dispose(); + this.exitListeners.delete(callback); }; } + + disposeExitSubscription(): void { + this.exitSubscription.dispose(); + this.exitListeners.clear(); + } } export const make = Effect.fn("NodePtyAdapter.make")(function* () { @@ -166,14 +270,16 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { ? { ...input.env, TERM: "xterm-256color" } : input.env; const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { + try: () => { + const nativeProcess = nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, env, name: "xterm-256color", - }), + }); + return { nativeProcess, process: new NodePtyProcess(nativeProcess, platform) }; + }, catch: (cause) => new PtyAdapter.PtySpawnError({ adapter: "node-pty", @@ -181,7 +287,16 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { cause, }), }); - return new NodePtyProcess(ptyProcess, platform); + if (platform === "win32") { + yield* waitForWindowsPid(ptyProcess.nativeProcess, ptyProcess.process, input.shell).pipe( + Effect.onError(() => + Effect.sync(() => ptyProcess.process.disposeExitSubscription()).pipe( + Effect.andThen(killStartingWindowsPty(ptyProcess.nativeProcess)), + ), + ), + ); + } + return ptyProcess.process; }), }); }); From 7733bc839e23e19467a70b11a0bcedf6ffe8f1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lima?= Date: Mon, 28 Sep 2026 23:40:42 +0100 Subject: [PATCH 05/16] fix(desktop): make Linux URL handlers discoverable with the app icon (#8673) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/app/DesktopLinuxUrlHandler.test.ts | 198 +++++++++++++++--- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 90 +++++++- .../src/app/DesktopPreReadyPlatform.test.ts | 35 +++- .../src/app/DesktopPreReadyPlatform.ts | 23 ++ 4 files changed, 311 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index f0b88101587c..893c1aaebe1d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,23 +1,29 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; interface RecordedRegistration { readonly directories: string[]; readonly files: Array<{ readonly path: string; readonly content: string }>; readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; + readonly copies: Array<{ readonly source: string; readonly destination: string }>; } -const makeEnvironment = (overrides: Record = {}) => +const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", isPackaged: true, @@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record = {}) => linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), - path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + path, ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); -const mockProcess = (exitCode: number) => +const mockProcess = (exitCode: number, stalled = false) => ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -50,16 +56,41 @@ const makeHandlerLayer = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly updateDesktopDatabaseExitCode?: number; + readonly updateDesktopDatabaseStalled?: boolean; + readonly updateDesktopDatabaseStarted?: Deferred.Deferred; readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; + readonly iconSource?: string; + readonly iconCopyError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Path.Path.pipe( + Effect.map((path) => makeEnvironment(path, input.environment)), + Effect.provide(Path.layer), + ), + ), + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + png: Option.fromUndefinedOr(input.iconSource), + ico: Option.none(), + icns: Option.none(), + }), + resolveResourcePath: () => Effect.succeedNone, + }), FileSystem.layerNoop({ + copyFile: (source, destination) => + input.iconCopyError + ? Effect.fail(input.iconCopyError) + : Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -79,11 +110,31 @@ const makeHandlerLayer = ( readonly command: string; readonly args: ReadonlyArray; }; + if (childProcess.command === "update-desktop-database") { + assert.isTrue( + recorded.files.length > 0 || input.existingEntry !== undefined, + "the desktop entry must exist before refreshing the MIME cache", + ); + } recorded.commands.push({ command: childProcess.command, args: childProcess.args, }); - return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + const exitCode = + childProcess.command === "update-desktop-database" + ? (input.updateDesktopDatabaseExitCode ?? 0) + : (input.xdgMimeExitCode ?? 0); + const handle = mockProcess( + exitCode, + childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStalled === true, + ); + return childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStarted + ? Deferred.succeed(input.updateDesktopDatabaseStarted, undefined).pipe( + Effect.as(handle), + ) + : Effect.succeed(handle); }), ), ), @@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({ directories: [], files: [], commands: [], + copies: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Nightly)", execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', scheme: "t3code", + iconPath: "/home/al ice/icons/T3\\x.png", }); assert.include(entry, "[Desktop Entry]"); @@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); it("carries structured context on registration errors", () => { @@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => { ); }); - it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { - const recorded = emptyRecording(); + it.effect( + "writes the handler entry, refreshes the MIME cache, and claims the scheme default", + () => { + const recorded = emptyRecording(); - return Effect.gen(function* () { - yield* runRegister(recorded); + return Effect.gen(function* () { + yield* runRegister(recorded); - assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); - assert.equal(recorded.files.length, 1); - assert.equal( - recorded.files[0]?.path, - "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - ); - assert.include( - recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', - ); - assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); - assert.deepEqual(recorded.commands, [ - { - command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], - }, - ]); - }); - }); + assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }, + ); it.effect("falls back to the process executable outside an AppImage", () => { const recorded = emptyRecording(); @@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Alpha)", execTarget: "/home/alice/Applications/T3-Code.AppImage", scheme: "t3code", + iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", }), }); assert.deepEqual(recorded.files, []); assert.deepEqual(recorded.directories, []); - assert.equal(recorded.commands.length, 1); + assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }); + + it.effect("installs a persistent icon even when the desktop entry is already current", () => { + const recorded = emptyRecording(); + const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, + }), + }); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.copies, [ + { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath }, + ]); + assert.equal(recorded.commands.at(-1)?.command, "xdg-mime"); + }); + }); + + it.effect("still registers the handler when copying its icon fails", () => { + const recorded = emptyRecording(); + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + iconCopyError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "copyFile", + description: "read-only icon directory", + }), + }); + assert.equal(recorded.files.length, 1); + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); }); }); @@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => { }); it.effect("never fails startup when registration cannot complete", () => { + const desktopDatabaseFailed = emptyRecording(); const xdgMimeFailed = emptyRecording(); const writeFailed = emptyRecording(); return Effect.gen(function* () { + yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 }); yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); yield* runRegister(writeFailed, { writeError: PlatformError.systemError({ @@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => { }), }); + assert.deepEqual( + desktopDatabaseFailed.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); assert.equal(xdgMimeFailed.files.length, 1); assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () => + Effect.gen(function* () { + const recorded = emptyRecording(); + const started = yield* Deferred.make(); + const registration = yield* runRegister(recorded, { + updateDesktopDatabaseStalled: true, + updateDesktopDatabaseStarted: started, + }).pipe(Effect.forkChild); + + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 404aff34c6bf..f01e81d890bc 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -8,6 +8,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -17,9 +18,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // Electron's app.setAsDefaultProtocolClient resolves the desktop id from // setDesktopName, which cannot match those files — so the browser keeps // prompting "Choose an application" for every OAuth callback. Instead, write -// our own handler entry pointing at the current AppImage and claim the -// scheme default via xdg-mime, exactly what the file manager's "set as -// default" checkbox would record in mimeapps.list. +// our own handler entry pointing at the current AppImage, refresh the desktop +// MIME cache so desktop environments recognize that entry as a handler, and +// use xdg-mime to record it as the scheme default in mimeapps.list. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -40,6 +41,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError< const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); +export class DesktopLinuxUrlHandlerCacheRefreshError extends Schema.TaggedError()( + "DesktopLinuxUrlHandlerCacheRefreshError", + { + applicationsDir: Schema.String, + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = + this.exitCode === undefined ? "" : `, update-desktop-database exit code ${this.exitCode}`; + return `Failed to refresh the desktop MIME cache at ${this.applicationsDir}${exitCode}.`; + } +} + +const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError); + const escapeDesktopEntryString = (value: string): string => value .replaceAll("\\", "\\\\") @@ -63,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string { return escapeDesktopEntryString(`"${quoted}"`); } -// The AppImage integration entry owns the window identity and icon. This +// The AppImage integration entry owns the window identity. This // hidden URL-only entry must not compete with it for StartupWMClass matching. export function renderUrlHandlerDesktopEntry(input: { readonly displayName: string; readonly execTarget: string; readonly scheme: string; + readonly iconPath?: string; }): string { return [ "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", "StartupNotify=false", @@ -95,12 +115,15 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const assets = yield* DesktopAssets.DesktopAssets; const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); const desktopEntryPath = environment.path.join( environment.linuxApplicationsDir, environment.linuxDesktopEntryName, ); + const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons"); + const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`); const writeDesktopEntry = Effect.gen(function* () { // Inside the mounted AppImage, process.execPath points at a transient @@ -110,6 +133,7 @@ export const make = Effect.gen(function* () { displayName: environment.displayName, execTarget, scheme, + ...(environment.isPackaged ? { iconPath } : {}), }); // Pre-ready setup normally wrote this already. Avoid truncating a valid // entry while the portal may be reading it during startup. @@ -131,6 +155,37 @@ export const make = Effect.gen(function* () { ), ); + const updateDesktopDatabase = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "update-desktop-database", + [environment.linuxApplicationsDir], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds")); + if (exitCode !== 0) { + return yield* new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + exitCode, + }); + } + }), + ).pipe( + Effect.mapError((error) => + isCacheRefreshError(error) + ? error + : new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + cause: error, + }), + ), + ); + const setDefaultHandler = Effect.scoped( Effect.gen(function* () { const command = ChildProcess.make( @@ -170,6 +225,33 @@ export const make = Effect.gen(function* () { } yield* writeDesktopEntry; if (!environment.isPackaged) return; + + yield* Effect.gen(function* () { + const { png } = yield* assets.iconPaths; + if (Option.isNone(png)) return; + // The AppImage mount is temporary; the chooser needs the icon after exit. + yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); + yield* fileSystem.copyFile(png.value, iconPath); + }).pipe( + Effect.catch((error) => + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), + ), + ); + + yield* updateDesktopDatabase.pipe( + // Some MIME implementations, including GIO, use mimeinfo.cache to verify + // that a desktop entry is associated with a scheme. Cache refresh is + // independently best-effort so a missing update-desktop-database executable + // does not prevent xdg-mime from recording the requested default. + Effect.catch((error) => + logWarning("desktop MIME cache refresh failed", { + applicationsDir: environment.linuxApplicationsDir, + message: error.message, + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + ); + yield* setDefaultHandler; yield* logInfo("registered URL scheme handler", { scheme }); }).pipe( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 9ddaf40caa0a..7e859aaf981a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -13,6 +13,7 @@ const { setDesktopNameMock, mkdirSyncMock, writeFileSyncMock, + copyFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -21,12 +22,15 @@ const { setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), + copyFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, getVersion: () => "0.0.37", + isPackaged: true, + getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, getSwitchValue: getSwitchValueMock, @@ -42,6 +46,7 @@ vi.mock("node:fs", () => ({ readFileSync: () => "{}", mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, + copyFileSync: copyFileSyncMock, })); import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -55,6 +60,7 @@ describe("DesktopPreReadyPlatform", () => { setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); + copyFileSyncMock.mockReset(); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -85,6 +91,10 @@ describe("DesktopPreReadyPlatform", () => { getSwitchValueMock.mockReturnValue(""); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; + let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); @@ -94,7 +104,11 @@ describe("DesktopPreReadyPlatform", () => { return Effect.scoped( Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry })); + const portalIdentity = Promise.resolve().then(() => ({ + desktopName, + desktopEntry, + iconInstalled, + })); yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), @@ -105,6 +119,11 @@ describe("DesktopPreReadyPlatform", () => { assert.include(identity.desktopEntry ?? "", 'Exec="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/Applications/current.AppImage" %U'); assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include( + identity.desktopEntry ?? "", + "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", + ); + assert.isTrue(identity.iconInstalled); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }, @@ -123,6 +142,20 @@ describe("DesktopPreReadyPlatform", () => { ); }); + it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { + getSwitchValueMock.mockReturnValue(""); + copyFileSyncMock.mockImplementation(() => { + throw new Error("missing bundled icon"); + }); + return Effect.gen(function* () { + yield* DesktopPreReadyPlatform.make; + const contents = writeFileSyncMock.mock.calls[0]?.[1]; + assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, "Icon="); + assert.equal(setDesktopNameMock.mock.calls.length, 1); + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + }); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..9b73a5d825bf 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -68,6 +68,28 @@ export const make = Effect.gen(function* () { "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); + const iconPath = Electron.app.isPackaged + ? NodePath.posix.join( + applicationsDir, + "..", + "icons", + `${linux.linuxDesktopEntryName}.png`, + ) + : undefined; + if (iconPath !== undefined) { + try { + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); + NodeFS.copyFileSync( + NodePath.posix.join( + Electron.app.getAppPath(), + "apps/desktop/prod-resources/icon.png", + ), + iconPath, + ); + } catch { + // Icon installation is optional; registration retries after readiness. + } + } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), renderUrlHandlerDesktopEntry({ @@ -77,6 +99,7 @@ export const make = Effect.gen(function* () { }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), + ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); From b21f3b71913370a1a650a61fd9082f992d40e657 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:31:43 +0530 Subject: [PATCH 06/16] fix(server): prevent Windows PTY helper crashes in watch mode (#14179) --- patches/node-pty@1.2.0-beta.15.patch | 35 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 5 ++-- pnpm-workspace.yaml | 1 + 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 patches/node-pty@1.2.0-beta.15.patch diff --git a/patches/node-pty@1.2.0-beta.15.patch b/patches/node-pty@1.2.0-beta.15.patch new file mode 100644 index 000000000000..2232766cdb65 --- /dev/null +++ b/patches/node-pty@1.2.0-beta.15.patch @@ -0,0 +1,35 @@ +diff --git a/lib/windowsConoutConnection.js b/lib/windowsConoutConnection.js +--- a/lib/windowsConoutConnection.js ++++ b/lib/windowsConoutConnection.js +@@ -63,7 +63,11 @@ + conoutPipeName: _conoutPipeName + }; + var scriptPath = __dirname.replace('node_modules.asar', 'node_modules.asar.unpacked'); +- this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData }); ++ // Node's watch mode reports dependencies through worker messages. ++ // Keep the host's watch environment out of this protocol worker. ++ var workerEnv = Object.assign({}, process.env); ++ delete workerEnv.WATCH_REPORT_DEPENDENCIES; ++ this._worker = new worker_threads_1.Worker((0, path_1.join)(scriptPath, 'worker/conoutSocketWorker.js'), { workerData: workerData, env: workerEnv, execArgv: [] }); + this._worker.on('message', function (message) { + switch (message) { + case 1 /* ConoutWorkerMessage.READY */: +diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js +--- a/lib/windowsPtyAgent.js ++++ b/lib/windowsPtyAgent.js +@@ -218,8 +218,14 @@ + return Promise.resolve([]); + } + return new Promise(function (resolve) { +- var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()]); ++ // Node's watch mode sends its own messages over the helper's IPC channel. ++ var agentEnv = Object.assign({}, process.env); ++ delete agentEnv.WATCH_REPORT_DEPENDENCIES; ++ var agent = (0, child_process_1.fork)(path.join(__dirname, 'conpty_console_list_agent'), [_this._innerPid.toString()], { env: agentEnv, execArgv: [] }); + agent.on('message', function (message) { ++ if (!Array.isArray(message.consoleProcessList)) { ++ return; ++ } + clearTimeout(timeout); + resolve(message.consoleProcessList); + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f579bfa8a5a8..d244cfee891c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,7 @@ patchedDependencies: expo-notifications@57.0.15: f89252c2c08dc7b3acb8415f319ebbe15fff8c9f88a64ebe1830cd5ac5c9ac40 expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 expo-widgets@57.0.15: 319a9ded5db49c5b5215c511a138b33f44c7ea2972eb418192e8d5342fe75ce6 + node-pty@1.2.0-beta.15: f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414 react-native-gesture-handler@2.32.0: 0579f8e4dad02bf3183d95b02620358412983c36f9bda7425dc8bcb9643b5ce2 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-markdown@0.5.8: 642b47830730acff3761fe29cf54271d18b493c0070c750097850b7ea032e00c @@ -532,7 +533,7 @@ importers: version: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) node-pty: specifier: ^1.2.0-beta.15 - version: 1.2.0-beta.15 + version: 1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414) stream-chain: specifier: ^4.2.5 version: 4.2.5 @@ -20110,7 +20111,7 @@ snapshots: node-mock-http@1.0.5: {} - node-pty@1.2.0-beta.15: + node-pty@1.2.0-beta.15(patch_hash=f2fe901c61cde17986240002d05c172d5d0272d83ffaab8d0ebeb922763be414): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56c740febf1f..8313ab360b27 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -196,6 +196,7 @@ patchedDependencies: expo-notifications@57.0.15: patches/expo-notifications@57.0.15.patch expo-sharing@57.0.17: patches/expo-sharing@57.0.17.patch expo-widgets@57.0.15: patches/expo-widgets@57.0.15.patch + node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch From 38f3c62304bfe79ea6c9f75a17afb732b555f48d Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Mon, 28 Sep 2026 18:00:36 -0700 Subject: [PATCH 07/16] fix(shared): local-path remotes no longer crash legacy PR link projection (#13463) Co-authored-by: Julius Marminge Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> --- packages/shared/src/threadPullRequests.test.ts | 12 ++++++++++++ packages/shared/src/threadPullRequests.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 4844df8f7786..a7042a7c8828 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -242,6 +242,18 @@ describe("legacyLinkedPullRequestOf", () => { it("does not guess when the project identity is unavailable", () => { expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, null)).toBeNull(); }); + it("does not route links for a local-path remote with no host or provider", () => { + const localIdentity = { + canonicalKey: "/tmp/r/remote", + displayName: "remote", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "/tmp/r/remote.git", + }, + }; + expect(legacyLinkedPullRequestOf([link(7)], "project-1" as never, localIdentity)).toBeNull(); + }); }); describe("resolveThreadPullRequestChains", () => { diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index e747c151bd1c..15b5451adf0a 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -157,7 +157,9 @@ export function legacyLinkedPullRequestOf( identity: RepositoryIdentity | null | undefined, ): ThreadLinkedPullRequest | null { if (!identity) return null; + // A local-path remote has no host segment and no provider, so there is no host to match. const host = pullRequestHostOf(identity, identity.provider as SourceControlProviderKind); + if (typeof host !== "string") return null; const repository = sourceControlRepositorySelector(identity); if (repository === null) return null; const azureKey = From adfc9240eab7c5db376a87591b497d8144289257 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:30:52 +0530 Subject: [PATCH 08/16] fix(server): preserve usage in oversized transcript records (#13650) --- .../src/project/AgentSessionJson.test.ts | 60 ++++ apps/server/src/project/AgentSessionJson.ts | 72 +++- apps/server/src/usage/UsageService.test.ts | 53 +++ .../server/src/usage/usageTranscriptReader.ts | 153 +++++++-- .../usage/usageTranscriptStreaming.test.ts | 323 ++++++++++++++++++ apps/server/src/usage/usageTranscripts.ts | 12 + 6 files changed, 636 insertions(+), 37 deletions(-) create mode 100644 apps/server/src/project/AgentSessionJson.test.ts create mode 100644 apps/server/src/usage/usageTranscriptStreaming.test.ts diff --git a/apps/server/src/project/AgentSessionJson.test.ts b/apps/server/src/project/AgentSessionJson.test.ts new file mode 100644 index 000000000000..515aa50b1b97 --- /dev/null +++ b/apps/server/src/project/AgentSessionJson.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { createTranscriptJsonReader, TranscriptJsonLimitError } from "./AgentSessionJson.ts"; + +function read( + json: string, + size: number, + select: (path: ReadonlyArray) => boolean = () => true, +) { + const reader = createTranscriptJsonReader(() => {}, select); + for (let offset = 0; offset < json.length; offset += size) + reader.write(json.slice(offset, offset + size)); + return reader.finish(); +} + +describe("transcript JSON projection", () => { + it.each([1, 2, 7, 64, 1024])("matches JSON.parse across %i-character boundaries", (size) => { + for (const text of [ + '{"a":1,"a":2,"b":"before","b":"after"}', + '{"a":{"x":1},"a":{"y":2},"b":[],"c":{}}', + '{"a":[null,true,false,1,-2.3e4,"😀\\u0061\\\\\\\"",{},[],[1,2]]}', + '{"a":"s","a":null,"b":null,"b":"s","c":0,"c":false}', + '{"__proto__":{"polluted":true},"constructor":1,"__proto__":2}', + ]) + expect(read(text, size)).toEqual(JSON.parse(text)); + }); + + it("projects siblings and array elements without merging repeated parent objects", () => { + const text = + '{"message":{"usage":{"input":100},"content":"large"},"message":{"usage":{"output":5},"content":[1,2]},"rows":[{"keep":1,"drop":2},{"keep":3}],"drop":{"keep":4}}'; + const projected = read(text, 1, (path) => { + if (path[0] === "drop") return false; + return !path.includes("content") && !path.includes("drop"); + }); + expect(projected).toEqual({ + message: { usage: { output: 5 } }, + rows: [{ keep: 1 }, { keep: 3 }], + }); + }); + + it.each(['{"a":', '{"a":1} trailing', '{"a":1}{"a":2}', '{"a":"bad\\x"}', '{"a":[1,]}'])( + "rejects malformed input %s", + (text) => { + expect(read(text, 1)).toBeUndefined(); + }, + ); + + it("retains the import allocation and depth limits", () => { + const limited = createTranscriptJsonReader( + () => { + throw new TranscriptJsonLimitError("budget"); + }, + () => true, + ); + expect(() => limited.write('{"a":1}')).toThrow(TranscriptJsonLimitError); + expect(() => read("[".repeat(129) + "0" + "]".repeat(129), 10)).toThrow( + TranscriptJsonLimitError, + ); + }); +}); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index d31c47833d70..4deba0a67794 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -1,7 +1,6 @@ import * as SchemaAST from "effect/SchemaAST"; -import { isMany, none, type Many } from "stream-chain/defs.js"; +import { none, type Many } from "stream-chain/defs.js"; import { Assembler } from "stream-json/core/assembler.js"; -import { filter } from "stream-json/core/filters/filter.js"; import * as StreamJson from "stream-json/core/parser.js"; import type { ParserOptions, Token } from "stream-json/core/parser.js"; @@ -55,6 +54,7 @@ export class TranscriptJsonLimitError extends Error {} export function createTranscriptJsonReader( reserve: (bytes: number) => void, selectPath: (path: JsonPath) => boolean, + options?: { readonly maxDepth?: number }, ) { // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted // from its bundled types. Unlike parser(), it does not wrap tokens in an @@ -65,9 +65,6 @@ export function createTranscriptJsonReader( ) => (input: string | typeof none) => Many | typeof none; }; const tokenize = jsonParser({ packValues: false }); - const select = filter({ filter: selectPath, streamKeys: false }) as ( - input: Token | typeof none, - ) => Token | Many | typeof none; const assembler = new Assembler(); let key: string | null = null; let value = ""; @@ -100,13 +97,62 @@ export function createTranscriptJsonReader( assembler.consume(token); } }; + // Forward actual selected keys instead of reconstructing them from path + // changes: adjacent duplicate keys have the same path but JSON.parse keeps + // the last value. Reconstructing paths can silently retain the first value. + const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; + let selectedValue = false; + const startValue = () => { + const parent = stack.at(-1); + const path = parent?.selected ? [...parent.path, parent.key] : []; + const selected = (parent?.selected ?? true) && selectPath(path); + if (selected && typeof parent?.key === "string") { + assemble({ name: "keyValue", value: parent.key }); + } + return { path, selected }; + }; + const endValue = () => { + const parent = stack.at(-1); + if (parent && typeof parent.key === "number") parent.key++; + }; const selectToken = (token: Token | typeof none) => { - const selected = select(token); - if (selected === none) return; - if (isMany(selected)) { - for (const item of selected.values) assemble(item); - } else { - assemble(selected); + if (token === none) return; + switch (token.name) { + case "keyValue": { + const parent = stack.at(-1); + if (parent) parent.key = token.value; + return; + } + case "startObject": + case "startArray": { + const frame = startValue(); + stack.push({ ...frame, key: token.name === "startArray" ? 0 : null }); + if (frame.selected) assemble(token); + return; + } + case "endObject": + case "endArray": + if (stack.pop()?.selected) assemble(token); + endValue(); + return; + case "startString": + case "startNumber": + selectedValue = startValue().selected; + if (selectedValue) assemble(token); + return; + case "endString": + case "endNumber": + if (selectedValue) assemble(token); + endValue(); + return; + case "nullValue": + case "trueValue": + case "falseValue": + if (startValue().selected) assemble(token); + endValue(); + return; + default: + if (selectedValue) assemble(token); } }; const consume = (input: string | typeof none) => { @@ -116,8 +162,8 @@ export function createTranscriptJsonReader( if (tokens === none) return; for (const token of tokens.values) { if (token.name === "startObject" || token.name === "startArray") { - if (++depth > 128) - throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels"); + if (++depth > (options?.maxDepth ?? 128)) + throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds the depth limit"); } else if (token.name === "endObject" || token.name === "endArray") { if (--depth === 0) complete = true; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 15ea4b673f22..df6d7ba044c8 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -645,6 +645,59 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const large = claudeLine(1, 9900).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, large)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 9900); + assert.closeTo( + first.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 0.4951, + 1e-12, + ); + const warm = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(warm.buckets, first.buckets); + // The repeated content block has the same message/request identity. + yield* Effect.promise(() => NodeFSP.appendFile(transcript, large + claudeLine(2, 100))); + const appended = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(appended), 10000); + assert.strictEqual( + appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), + 20, + ); + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, appended.buckets); + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const afterCleanup = yield* UsageService.make; + assert.deepStrictEqual( + (yield* afterCleanup.readSummary(WINDOW)).buckets, + appended.buckets, + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-large-record-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..faa686990777 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -17,15 +17,21 @@ */ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeStringDecoder from "node:string_decoder"; import type { UsageProviderKind } from "@t3tools/contracts"; +import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; + import { initialCodexScanState, mightCarryUsage, parseClaudeLine, + parseClaudeRecord, parseCodexLine, + parseCodexRecord, parseGrokLine, + parseGrokRecord, type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -74,9 +80,62 @@ export interface TranscriptParseResult { /** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ export const GUARD_LENGTH = 64; +// Native parsing is faster for common 1–4 MiB context/tool records. Above +// 8 MiB, project usage without allocating the whole record. This switches +// readers; it never discards a record because of its size. +const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; const CARRIAGE_RETURN = 0x0d; +type SelectedFields = { readonly [key: string]: true | SelectedFields }; + +// Keep the fields consumed by usageTranscripts, including reducer state and +// dedupe/cost metadata. A selected subtree (usage) keeps future token fields. +const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { + claude: { + type: true, + timestamp: true, + requestId: true, + sessionId: true, + costUSD: true, + message: { id: true, model: true, usage: true }, + }, + codex: { + type: true, + timestamp: true, + payload: { + type: true, + id: true, + session_id: true, + model: true, + forked_from_id: true, + source: { subagent: { thread_spawn: { parent_thread_id: true } } }, + info: { last_token_usage: true }, + }, + }, + grok: { + timestamp: true, + params: { + sessionId: true, + _meta: { agentTimestampMs: true }, + update: { sessionUpdate: true, prompt_id: true, usage: true }, + }, + }, +}; + +function selectUsageFields(provider: UsageProviderKind) { + const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"]; + return (path: ReadonlyArray): boolean => { + let selected: true | SelectedFields = fields; + for (const key of path) { + if (selected === true) return true; + if (typeof key !== "string" || !Object.hasOwn(selected, key)) return false; + selected = selected[key]!; + } + return true; + }; +} + function fnv1a(buffer: Buffer): number { let hash = 0x811c9dc5; for (let index = 0; index < buffer.length; index += 1) { @@ -194,7 +253,9 @@ export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, resumeFrom?: TranscriptParsePosition, + options?: { readonly streamingThresholdBytes?: number }, ): Promise { + const streamingThresholdBytes = options?.streamingThresholdBytes ?? STREAMING_THRESHOLD_BYTES; let handle: NodeFSP.FileHandle; try { handle = await NodeFSP.open(filePath, "r"); @@ -248,43 +309,87 @@ export async function readTranscriptRecords( }; const records: UsageRecord[] = []; - // Buffer-level line splitting rather than `readline`, because resuming - // needs byte-exact offsets and decoded strings cannot provide them. - // Newline-free chunks are collected rather than concatenated as they - // arrive, so a single huge line costs one copy instead of one per chunk. + // Byte offsets remain independent of UTF-8 decoding. Only complete lines + // commit the resume point; an unfinished tail is replayed on the next scan. let resumeOffset = start; + let scanOffset = start; let pendingChunks: Buffer[] = []; + let pendingBytes = 0; + let streaming: ReturnType | undefined; + let decoder: NodeStringDecoder.StringDecoder | undefined; + const selectPath = selectUsageFields(provider); + + const append = (segment: Buffer) => { + if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { + if (segment.length > 0) pendingChunks.push(segment); + pendingBytes += segment.length; + return; + } + if (!streaming) { + // Usage has no import-history budget: retain all selected usage fields, + // regardless of the size of the surrounding unselected tool content. + streaming = createTranscriptJsonReader(() => {}, selectPath, { maxDepth: Infinity }); + decoder = new NodeStringDecoder.StringDecoder("utf8"); + for (const pending of pendingChunks) streaming.write(decoder.write(pending)); + pendingChunks = []; + pendingBytes = 0; + } + streaming.write(decoder!.write(segment)); + }; + const finish = (state: CodexScanState, out: UsageRecord[]) => { + if (streaming) { + streaming.write(decoder!.end()); + const projected = streaming.finish(); + if (provider === "grok") { + out.push(...parseGrokRecord(projected)); + } else { + const record = + provider === "codex" + ? parseCodexRecord(projected, state) + : parseClaudeRecord(projected); + if (record !== null) out.push(record); + } + } else if (pendingBytes > 0) { + const line = + pendingChunks.length === 1 + ? pendingChunks[0]! + : Buffer.concat(pendingChunks, pendingBytes); + parseLine(toLineString(line), state, out); + } + pendingChunks = []; + pendingBytes = 0; + streaming = undefined; + decoder = undefined; + }; const stream = handle.createReadStream({ start, autoClose: false, + highWaterMark: 256 * 1024, }) as AsyncIterable; for await (const chunk of stream) { - if (!chunk.includes(NEWLINE)) { - pendingChunks.push(chunk); - continue; - } - const buffer: Buffer = - pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); - pendingChunks = []; let lineStart = 0; - for (;;) { - const newlineIndex = buffer.indexOf(NEWLINE, lineStart); - if (newlineIndex === -1) break; - parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + while (lineStart < chunk.length) { + const newlineIndex = chunk.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) { + append(chunk.subarray(lineStart)); + break; + } + // Most lines fit in the current chunk. Avoid buffering/streaming + // machinery on this hot path. + if (!streaming && pendingBytes === 0) { + parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); + } else { + append(chunk.subarray(lineStart, newlineIndex)); + finish(codexState, records); + } lineStart = newlineIndex + 1; + resumeOffset = scanOffset + lineStart; } - resumeOffset += lineStart; - if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + scanOffset += chunk.length; } - // A trailing segment without its newline is parsed for this result but not - // consumed: a writer may still be appending to it, and counting a half - // record now and its full form later would double count. const tailRecords: UsageRecord[] = []; - if (pendingChunks.length > 0) { - const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); - if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); - } + finish({ ...codexState }, tailRecords); const guardLength = Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts new file mode 100644 index 000000000000..2187fd4aa431 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercise the real byte reader. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; + +import { + readTranscriptRecords as readWithDefaultThreshold, + type TranscriptParsePosition, +} from "./usageTranscriptReader.ts"; + +// Exercise the same transition with compact fixtures. UsageService tests and +// external 65/517 MiB fixtures also exercise the production threshold. +const readTranscriptRecords = ( + path: string, + provider: "claude" | "codex" | "grok", + position?: TranscriptParsePosition, +) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 }); + +let dir: string; +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-stream-")); +}); +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +const timestamp = "2026-08-01T10:00:00Z"; +const content = '工具 output \\" usage token_count '.repeat(40_000); +const claude = (id = "m1", output = 99) => ({ + type: "assistant", + timestamp, + sessionId: "s1", + requestId: `r-${id}`, + costUSD: 0.25, + message: { + content: [{ type: "tool_use", input: { text: content } }], + id, + model: "claude-fable-5", + usage: { + input_tokens: 100, + output_tokens: output, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 5, + speed: "fast", + }, + }, +}); +const codex = [ + { type: "session_meta", timestamp, payload: { id: "s1" } }, + { type: "turn_context", timestamp, payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 100, + output_tokens: 99, + cached_input_tokens: 20, + cache_write_input_tokens: 5, + reasoning_output_tokens: 10, + }, + }, + }, + }, +]; +const grok = { + timestamp: 1785578400, + params: { + sessionId: "s1", + _meta: { agentTimestampMs: 1785578400123 }, + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 100, + outputTokens: 99, + costUsdTicks: 2500000000, + modelUsage: { + "grok-4.5-build": { + inputTokens: 100, + outputTokens: 99, + cachedReadTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 10, + }, + }, + }, + }, + }, +}; + +async function scan( + lines: readonly unknown[], + provider: "claude" | "codex" | "grok", + name = "history", +) { + const path = NodePath.join(dir, `${name}.jsonl`); + await NodeFSP.writeFile(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n"); + const result = await readTranscriptRecords(path, provider); + expect(result).not.toBeNull(); + return result!; +} + +describe("large usage records", () => { + it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { + const result = await scan([claude()], "claude"); + expect(result.records).toEqual([ + { + provider: "claude", + timestampMs: Date.parse(timestamp), + sessionId: "s1", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + outputTokens: 99, + cachedInputTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 0, + }, + reportedCostUsd: 0.25, + fast: true, + dedupeKey: "m1:r-m1", + }, + ]); + }); + + it.each(["claude", "codex", "grok"] as const)( + "matches ordinary %s records with large irrelevant fields in either order", + async (provider) => { + const small = + provider === "codex" + ? codex + : provider === "grok" + ? [grok] + : [{ ...claude(), message: { ...claude().message, content: [] } }]; + const expected = await scan(small, provider, "small"); + for (const first of [true, false]) { + const large = small.map((record) => + first ? { padding: content, ...record } : { ...record, padding: content }, + ); + const actual = await scan(large, provider); + expect(actual.records).toEqual(expected.records); + expect(actual.position.codexState).toEqual(expected.position.codexState); + } + }, + ); + + it("preserves Grok model allocation, precise timestamp and prompt identity", async () => { + const result = await scan([{ padding: content, ...grok }], "grok"); + expect(result.records[0]).toMatchObject({ + timestampMs: 1785578400123, + model: "grok-4.5-build", + sessionId: "s1", + totals: { + uncachedInputTokens: 75, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 99, + reasoningTokens: 10, + }, + dedupeKey: "s1:p1:grok-4.5-build", + reportedCostUsd: 0.25, + }); + }); + + it("does not count usage-looking text or nested objects inside tool output", async () => { + const result = await scan( + [ + { type: "user", padding: content, toolOutput: claude() }, + { padding: content, message: { content: JSON.stringify(claude()) } }, + { type: "assistant", timestamp, message: { model: "claude-fable-5", content: [claude()] } }, + ], + "claude", + ); + expect(result.records).toEqual([]); + }); + + it("replays partial UTF-8 and JSON tails, commits exact CRLF offsets, and replaces the tail once", async () => { + const path = NodePath.join(dir, "tail.jsonl"); + const first = JSON.stringify(claude("first", 5)) + "\r\n"; + const second = Buffer.from(JSON.stringify(claude("second", 7))); + const split = second.lastIndexOf(Buffer.from("工具")) + 1; + await NodeFSP.writeFile(path, Buffer.concat([Buffer.from(first), second.subarray(0, split)])); + const partial = await readTranscriptRecords(path, "claude"); + expect(partial?.records.map((r) => r.totals.outputTokens)).toEqual([5]); + expect(partial?.tailRecords).toEqual([]); + expect(partial?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, second.subarray(split)); + const complete = await readTranscriptRecords(path, "claude", partial!.position); + expect(complete?.resumed).toBe(true); + expect(complete?.records).toEqual([]); + expect(complete?.tailRecords.map((r) => r.totals.outputTokens)).toEqual([7]); + expect(complete?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, "\r\n" + JSON.stringify(claude("third", 11)) + "\n"); + const appended = await readTranscriptRecords(path, "claude", complete!.position); + expect(appended?.records.map((r) => r.totals.outputTokens)).toEqual([7, 11]); + expect(appended?.tailRecords).toEqual([]); + expect(appended?.position.resumeOffset).toBe((await NodeFSP.stat(path)).size); + const full = await readTranscriptRecords(path, "claude"); + expect([...partial!.records, ...appended!.records]).toEqual(full?.records); + }); + + it("preserves Codex model switches and duplicate suppression across streaming resumes", async () => { + const path = NodePath.join(dir, "history.jsonl"); + const first = await scan( + codex.map((record) => ({ padding: content, ...record })), + "codex", + ); + await NodeFSP.appendFile( + path, + [ + { padding: content, ...codex[2] }, + { type: "turn_context", payload: { padding: content, model: "gpt-6" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + padding: content, + info: { last_token_usage: { input_tokens: 200, output_tokens: 101 } }, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n") + "\n", + ); + const result = await readTranscriptRecords(path, "codex", first.position); + expect(result?.resumed).toBe(true); + expect(result?.records).toHaveLength(1); + expect(result?.records[0]).toMatchObject({ + model: "gpt-6", + sessionId: "s1", + totals: { outputTokens: 101 }, + }); + const full = await readTranscriptRecords(path, "codex"); + expect([...first.records, ...result!.records]).toEqual(full?.records); + }); + + it.each(["forked_from_id", "source"])( + "preserves Codex fork-copy suppression from %s", + async (field) => { + const fork = + field === "source" + ? { source: { subagent: { thread_spawn: { parent_thread_id: "parent" } } } } + : { forked_from_id: "parent" }; + const lines = [ + { type: "session_meta", timestamp, payload: { padding: content, id: "child", ...fork } }, + codex[1], + codex[2], + { + ...codex[2], + timestamp: "2026-08-01T10:00:05Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: 101 } }, + }, + }, + ]; + const result = await scan(lines, "codex"); + expect(result.records).toHaveLength(1); + expect(result.records[0]).toMatchObject({ + sessionId: "child", + totals: { outputTokens: 101 }, + }); + }, + ); + + it("rejects malformed large lines without accepting partial usage or losing following lines", async () => { + const valid = JSON.stringify(claude()); + const path = NodePath.join(dir, "broken.jsonl"); + await NodeFSP.writeFile( + path, + [valid + " junk", valid.slice(0, -1), valid + valid, JSON.stringify(claude("good", 7))].join( + "\n", + ) + "\n", + ); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records.map((r) => r.totals.outputTokens)).toEqual([7]); + }); + + it("matches JSON.parse for reordered and escaped keys, duplicate fields, arrays and unknown key names", async () => { + const path = NodePath.join(dir, "odd.jsonl"); + const small = JSON.stringify({ ...claude(), message: { ...claude().message, content: [] } }); + const variants = [ + small.replace('"message":', '"mess\\u0061ge":'), + small.replace('"costUSD":0.25', '"costUSD":5,"costUSD":0.25'), + small.replace('"type":"assistant"', '"type":"user","type":"assistant"'), + small.replace('"message":', '"__proto__":{"type":"user"},"message":'), + small.replace('"message":', '"message.usage":{"output_tokens":1234},"message":'), + ]; + for (const line of variants) { + await NodeFSP.writeFile(path, line + "\n"); + const expected = await readTranscriptRecords(path, "claude"); + await NodeFSP.writeFile( + path, + '{"padding":' + JSON.stringify(content) + "," + line.slice(1) + "\n", + ); + const actual = await readTranscriptRecords(path, "claude"); + expect(actual?.records).toEqual(expected?.records); + } + }); + it("reads usage after deeply nested discarded tool content", async () => { + const path = NodePath.join(dir, "deep.jsonl"); + const record = JSON.stringify(claude()); + const nested = "[".repeat(300) + "0" + "]".repeat(300); + await NodeFSP.writeFile(path, '{"toolOutput":' + nested + "," + record.slice(1) + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); + + it("keeps JSON number semantics for non-finite values without serializing them into null", async () => { + const path = NodePath.join(dir, "numbers.jsonl"); + const record = JSON.stringify(claude()).replace('"costUSD":0.25', '"costUSD":1e400'); + await NodeFSP.writeFile(path, record + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.reportedCostUsd).toBeNull(); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 6e01c2c5a8ed..c3903ef47194 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -113,6 +113,10 @@ export function parseClaudeLine(line: string): UsageRecord | null { } catch { return null; } + return parseClaudeRecord(parsed); +} + +export function parseClaudeRecord(parsed: unknown): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -228,6 +232,10 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord } catch { return null; } + return parseCodexRecord(parsed, state); +} + +export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -385,6 +393,10 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { } catch { return []; } + return parseGrokRecord(parsed); +} + +export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; From e518866d28603da273a9357516dd1aef919f1353 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:12:40 -0400 Subject: [PATCH 09/16] fix(server): OpenCode stop no longer hangs when a turn is interrupted before submission (#12003) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../provider/Layers/OpenCodeAdapter.test.ts | 51 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 13 ++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index baded8094ba6..40d034089ae9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -781,6 +782,56 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "stopSession completes after sendTurn is interrupted before the prompt is submitted", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupted-before-submit"); + runtimeMock.state.createdSessionIds.push("ses_interrupted_before_submit"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const baseClock = yield* Clock.clockWith(Effect.succeed); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const gatedClock: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: () => baseClock.currentTimeMillisUnsafe(), + currentTimeMillis: Effect.suspend(() => { + calls += 1; + return calls === 2 + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(baseClock.currentTimeMillis), + ) + : baseClock.currentTimeMillis; + }), + }; + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt must not be submitted", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.provideService(Clock.Clock, gatedClock), Effect.exit, Effect.forkChild); + yield* Deferred.await(reached); + yield* Fiber.interrupt(sendFiber); + + const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit); + NodeAssert.equal(Exit.isSuccess(stopExit), true); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("aborts a held teardown request before closing the session scope", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 04535edbd3af..34ebd1b544fb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3531,7 +3531,18 @@ export function makeOpenCodeAdapter( ? { resumeCursor: context.session.resumeCursor } : {}), }; - }), + }).pipe( + // stopSession waits on submissionSettled; an interrupted sendTurn + // must not leave it pending. + Effect.ensuring( + Effect.suspend(() => { + const admission = context.promptAdmission; + return admission + ? Deferred.succeed(admission.submissionSettled, undefined).pipe(Effect.ignore) + : Effect.void; + }), + ), + ), ); }); From 5da55957d48dbd79c1a05b72bdf2479ae0956b1c Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:38:24 -0400 Subject: [PATCH 10/16] feat: save Bitbucket credentials from Source Control settings (#14103) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/src/serverSettings.test.ts | 136 +++++++++++ apps/server/src/serverSettings.ts | 101 +++++++- .../src/sourceControl/BitbucketApi.test.ts | 77 +++++- apps/server/src/sourceControl/BitbucketApi.ts | 100 ++++++-- .../BitbucketSourceControlProvider.ts | 3 +- .../SourceControlDiscovery.test.ts | 2 +- .../settings/BitbucketCredentialsSettings.tsx | 223 ++++++++++++++++++ .../settings/SourceControlSettings.tsx | 24 +- .../src/components/settings/settingsSearch.ts | 8 + docs/user/source-control.md | 27 ++- packages/contracts/src/pullRequest.ts | 4 +- packages/contracts/src/settings.ts | 22 ++ 13 files changed, 678 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/settings/BitbucketCredentialsSettings.tsx diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..4c6e04bb510e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; @@ -155,7 +156,8 @@ await Effect.runPromise( GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, - BitbucketApi.layer, + // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. + BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), ), ), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..502ae3430e27 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => ), ); +/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ +const makeServerSettingsLayerWithSecrets = () => + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => Layer.succeed( ServerSecretStore.ServerSecretStore, @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + }); + assert.deepEqual(saved.bitbucket, { + email: "me@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "bb-access"); + assert.notInclude(raw, "bb-api"); + assert.include(raw, "me@example.com"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; + assert.equal(forClient.email, "me@example.com"); + assert.notInclude(forClient.accessToken, "bb-access"); + assert.notInclude(forClient.apiToken, "bb-api"); + assert.isAbove(forClient.accessToken.length, 0); + assert.isAbove(forClient.apiToken.length, 0); + + // A client echoing the redacted values back, or omitting them, keeps the saved tokens. + yield* serverSettings.updateSettings({ bitbucket: forClient }); + yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); + assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { + email: "other@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); + assert.equal(cleared.bitbucket.accessToken, ""); + assert.equal(cleared.bitbucket.apiToken, "bb-api"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, + "", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A token was saved, then the user deleted it from settings.json directly. + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"accessToken":"hand-edited-token"}}', + ); + + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; + + assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + const stored = yield* secrets.get("bitbucket-access-token"); + assert.equal( + Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect( + "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + ); + + // The form resends the redacted token when only the email changes. + const forClient = ServerSettingsModule.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ).bitbucket; + const updated = yield* serverSettings.updateSettings({ + bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, + }); + + assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); + assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..6949a66d981b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { } /** - * On disk the hub key is replaced by this marker and the real value lives in - * the secret store, mirroring provider environment secrets. A client that - * sends the marker back means "keep what you have". + * On disk a hub key or Bitbucket token is replaced by this marker and the + * real value lives in the secret store, mirroring provider environment + * secrets. A client that sends the marker back means "keep what you have". */ -const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +const BITBUCKET_SECRET_NAMES = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +} as const; +const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; + +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS id, { ...source, - managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + managementKey: redactSecret(source.managementKey), }, ]), ); - return { ...settings, providerInstances, usageLimitSources }; + const bitbucket = { + ...settings.bitbucket, + accessToken: redactSecret(settings.bitbucket.accessToken), + apiToken: redactSecret(settings.bitbucket.apiToken), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket }; } export class ServerSettingsService extends Context.Service< @@ -555,6 +568,35 @@ const make = Effect.gen(function* () { ), ); + /** + * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, + * so plaintext does not stay on disk. If the store is unavailable, the token keeps working + * from the file and the move is retried on the next load. + */ + const moveInlineBitbucketTokens = (settings: ServerSettings) => + Effect.gen(function* () { + const bitbucket = { ...settings.bitbucket }; + let moved = false; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a Bitbucket token into the secret store", { + field, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + bitbucket[field] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket } : settings; + }); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - if (folded !== loaded) { - yield* writeSettingsAtomically(folded); + // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. + const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; + if (migrated !== loaded) { + yield* writeSettingsAtomically(migrated); } - return folded; + return migrated; }); const settingsCache = yield* Cache.make({ @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { } const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey !== SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } + const bitbucket = { ...settings.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + if (bitbucket[field] !== SECRET_REDACTED) continue; + const secret = yield* secretStore + .get(BITBUCKET_SECRET_NAMES[field]) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }; }); @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey === SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { secretName, value: textEncoder.encode(source.managementKey), }); - usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; @@ -854,11 +911,31 @@ const make = Effect.gen(function* () { }); } + const bitbucket = { ...next.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + let value = bitbucket[field]; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved. A plaintext value hand-edited into settings.json + // is not in the secret store yet, so move it there instead of dropping it. + const inline = current.bitbucket[field]; + if (inline === SECRET_REDACTED || inline.length === 0) continue; + value = inline; + } + const secretName = BITBUCKET_SECRET_NAMES[field]; + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + bitbucket[field] = SECRET_REDACTED; + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..83965a38b21c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -16,6 +16,7 @@ import { import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -64,6 +65,7 @@ function makeLayer(input: { request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; readonly git?: Partial; + readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.requestFailure @@ -150,7 +152,7 @@ function makeLayer(input: { Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { + env: input.env ?? { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", T3CODE_BITBUCKET_EMAIL: "user@example.com", T3CODE_BITBUCKET_API_TOKEN: "token", @@ -158,6 +160,7 @@ function makeLayer(input: { }), ), ), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("prefers credentials saved in settings over the environment, without a restart", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization; + const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`; + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + + yield* settings.updateSettings({ + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + }).pipe(Effect.provide(layer)); +}); + +it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + // Fetch would reject this header with an error quoting the token, and that error reaches + // clients. The unusable token is ignored, so the environment credential is used instead. + yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* bitbucket.probeAuth; + assert.strictEqual( + execute.mock.calls.at(-1)?.[0].headers.authorization, + `Basic ${btoa("user@example.com:token")}`, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { + const { layer } = makeLayer({ + response: () => new Response(null, { status: 401 }), + env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + assert.deepStrictEqual(yield* bitbucket.probeAuth, { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("An access token is configured."), + }); + }).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; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..2404e6b497e3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, + type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -28,6 +30,7 @@ import { } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { return repositoryName.split("/")[0]?.trim() || "bitbucket"; } -function authFromConfig( - config: Config.Success, -): SourceControlProviderAuth { - if (Option.isSome(config.accessToken)) { +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (HEADER_SAFE.test(input.accessToken)) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { return { status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }; } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + if (credential?.kind === "api-token") { return { status: "unknown", - account: config.email, + account: Option.some(credential.email), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket API token is configured."), + detail: Option.some("An API token is configured."), }; } @@ -563,7 +606,7 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }; } @@ -612,6 +655,7 @@ function responseError( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const withAuth = (request: HttpClientRequest.HttpClientRequest) => { - if (Option.isSome(config.accessToken)) { - return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); - } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { - return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); - } - return request; - }; + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = serverSettings.getSettings.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); const decodeResponse = ( operation: BitbucketApiOperation, @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return httpClient.execute(withAuth(withBody)).pipe( + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { host: Option.some("bitbucket.org"), detail: Option.none(), })), - Effect.orElseSucceed(() => authFromConfig(config)), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), ), listPullRequests: (input) => resolveRepository(input).pipe( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e27aa4c7dc88..f1a01056fbd0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { type: "api", kind: "bitbucket", label: "Bitbucket", - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", + installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..820b9a76c9a2 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }), }, diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx new file mode 100644 index 000000000000..a423334d1c7f --- /dev/null +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -0,0 +1,223 @@ +import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; + +type CredentialMethod = "access-token" | "api-token"; + +const METHODS: Record< + CredentialMethod, + { + readonly label: string; + readonly description: string; + readonly link: string; + readonly linkLabel: string; + } +> = { + "access-token": { + label: "Access token", + description: + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", + link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", + linkLabel: "Learn more", + }, + "api-token": { + label: "API token", + description: + "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + link: "https://id.atlassian.com/manage-profile/security/api-tokens", + linkLabel: "Create an API token", + }, +}; + +function savedMethod(saved: BitbucketSettings): CredentialMethod | null { + if (saved.accessToken.length > 0) return "access-token"; + if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; + return null; +} + +/** A write-only token field. It never shows the saved token; typing a new one replaces it. */ +function TokenInput({ + id, + isSaved, + draft, + onDraftChange, +}: { + readonly id: string; + readonly isSaved: boolean; + readonly draft: string; + readonly onDraftChange: (draft: string) => void; +}) { + return ( + onDraftChange(event.target.value)} + /> + ); +} + +/** + * Bitbucket credentials for one environment: an access token or an Atlassian + * account email + API token, never both. Tokens are write-only: the server + * keeps them in its secret store and only reports whether each one is set. + */ +export function BitbucketCredentialsSettings({ + environmentId, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly onSaved: () => void; +}) { + const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save Bitbucket credentials", + }); + const [methodChoice, setMethodChoice] = useState(null); + const [accessToken, setAccessToken] = useState(""); + const [emailDraft, setEmailDraft] = useState(null); + const [apiToken, setApiToken] = useState(""); + const [saving, setSaving] = useState(false); + const current = savedMethod(saved); + const method = methodChoice ?? current ?? "access-token"; + const methodIsSaved = current === method; + const email = (emailDraft ?? saved.email).trim(); + const newAccessToken = accessToken.trim(); + const newApiToken = apiToken.trim(); + const info = METHODS[method]; + + // Saving one method clears the other, so a hidden credential never wins over the visible one. + const patch: BitbucketSettings | null = + method === "access-token" + ? newAccessToken + ? { accessToken: newAccessToken, email: "", apiToken: "" } + : null + : email && (newApiToken || saved.apiToken) + ? // Resending the saved token's redacted value keeps it. + { accessToken: "", email, apiToken: newApiToken || saved.apiToken } + : null; + const canSave = + patch !== null && + (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email); + + const save = async (next: BitbucketSettings) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { bitbucket: next } }, + }); + if (result._tag === "Success") { + setAccessToken(""); + setApiToken(""); + setEmailDraft(null); + onSaved(); + } + } finally { + setSaving(false); + } + }; + + return ( +
{ + event.preventDefault(); + if (canSave && patch) void save(patch); + }} + > + {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */} +
+ { + const value = next[0]; + if (value === "access-token" || value === "api-token") setMethodChoice(value); + }} + > + {METHODS["access-token"].label} + {METHODS["api-token"].label} + +

+ {info.description}{" "} + }> + {info.linkLabel} + + +

+ {method === "access-token" ? ( +
+ + +
+ ) : ( + <> +
+ + setEmailDraft(event.target.value)} + /> +
+
+ + +
+ + )} +
+

+ {current === null + ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." + : methodIsSaved + ? null + : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`} +

+
+ {current !== null ? ( + + ) : null} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10f86084ea20..9fba0edc7d44 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -56,6 +56,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { @@ -234,7 +235,9 @@ function itemSummary({ ); } - if (!item.executable) { + // API integrations have no CLI to sign in with; an unverified saved credential falls + // through to the "could not verify" detail instead of repeating the setup hint. + if (!item.executable && auth.status === "unauthenticated") { return Available. {item.installHint}; } @@ -277,7 +280,11 @@ function DiscoveryItemRow({ const searchTargetId = useSettingsSearchTargetId(); useEffect(() => { - if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + if ( + (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || + (item.kind === "bitbucket" && + searchTargetId === searchableSetting("bitbucket-credentials").id) + ) { setIsExpanded(true); } }, [item.kind, searchTargetId]); @@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + + {item.kind === "bitbucket" ? ( + + + + ) : undefined} + ))} ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c9cb76401bd6..4edce393883c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "bitbucket-credentials", + title: "Bitbucket credentials", + to: "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/settings/source-control", + searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], + environmentOnly: true, + scope: "environment-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..0427f143c7d5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -47,23 +47,29 @@ glab auth login ### Bitbucket -Set an access token in the server's environment: +Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: -```bash -export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" -``` +- **Access token**: a token created for one repository, project, or workspace. It can only reach + what it was created for. +- **API token**: an Atlassian API token for your account, used with your account email. It can + reach every repository you can. Give it read/write access to repositories and pull requests, plus + user read access (`read:user:bitbucket`). -Or use an Atlassian account email and API token with read/write access to repositories and pull -requests, plus user read access (`read:user:bitbucket`): +Choose **Save**; the change applies right away, and replaces any credential saved with the other +method. Credentials are saved on the environment's server, so select a remote environment to +configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose +**Remove**. + +If no credentials are saved, T3 Code falls back to these variables in the server's environment. +Restart the server after changing them: ```bash +export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" +# or export T3CODE_BITBUCKET_EMAIL="you@example.com" export T3CODE_BITBUCKET_API_TOKEN="your-token" ``` -The access token takes precedence if both are configured. Restart the server after changing these -variables. - ### Azure DevOps Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: @@ -138,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop. ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, - confirm the running server received the environment variables. + check the credentials saved in Settings → Source Control, or confirm the running server received + the environment variables. - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access. diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1a3c2d50b209..7a249cefb373 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial< }, bitbucket: { missing: - "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", unauthenticated: - "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", }, }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..3fd27b7f7379 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +/** + * Bitbucket API credentials for this environment, used before the + * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's + * secret store; settings and clients only see a redaction marker when one is + * set. The access token wins when both kinds are configured. + */ +export const BitbucketSettings = Schema.Struct({ + email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), +}); +export type BitbucketSettings = typeof BitbucketSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), + /** An empty token clears it; an omitted one keeps what the server has. */ + bitbucket: Schema.optionalKey( + Schema.Struct({ + email: Schema.optionalKey(TrimmedString), + accessToken: Schema.optionalKey(TrimmedString), + apiToken: Schema.optionalKey(TrimmedString), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), From 76fa23df2ef09c8d9624cc6ce1273d32452b3d1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 19:00:28 -0700 Subject: [PATCH 11/16] fix(provider): mark OpenCode v2 incompatible (#14198) --- .../src/features/threads/ThreadComposer.tsx | 6 ++- apps/server/src/provider/model-manifest.json | 8 +-- .../provider/providerCompatibility.test.ts | 18 ------- .../providerMaintenanceRunner.test.ts | 6 ++- .../chat/ProviderStatusBanner.test.ts | 54 +++++++++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 32 +++++++---- 6 files changed, 92 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d131dfaa51f1..d4a845c197a0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -663,7 +663,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "assertive" : "polite" } - className="px-3 py-2 text-xs text-foreground" + className={ + selectedProviderStatus.compatibilityAdvisory.status === "broken" + ? "bg-danger px-3 py-2 text-xs text-danger-foreground" + : "px-3 py-2 text-xs text-foreground" + } > {selectedProviderStatus.compatibilityAdvisory.message} diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 87efe519ea1c..97734ea49060 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-28T20:00:00Z", + "updatedAt": "2026-09-29T01:03:44Z", "compatibility": [ { "driver": "codex", @@ -43,9 +43,11 @@ { "driver": "opencode", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=1.14.19", + "recommendedRange": ">=1.14.19 <2.0.0", + "recommendedVersion": "1.14.19", "ranges": [ - { "range": ">=1.14.19", "status": "supported" }, + { "range": ">=2.0.0", "status": "broken" }, + { "range": ">=1.14.19 <2.0.0", "status": "supported" }, { "range": "<1.14.19", "status": "broken" } ] }, diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 4d438a77b54e..3a43356d2caf 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -65,24 +65,6 @@ describe("provider compatibility", () => { } }); - it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => { - const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; - for (const [t3CodeVersion, codexVersion, expected] of [ - ["0.0.42", "0.148.0", "broken"], - ["0.0.42", "0.149.0", "unsupported"], - ["0.0.42", "0.155.0", "unsupported"], - ["0.0.42", "0.156.0", "supported"], - ["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"], - ["0.0.43-nightly.20260924.2200", "0.156.1", "supported"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status, - expected, - `T3 Code ${t3CodeVersion} with Codex ${codexVersion}`, - ); - } - }); - it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 998c301cb1fb..194dd4fb4ba1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -216,7 +216,11 @@ const makeTestRunner = ( manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, - compatibility: [{ driver: CODEX_DRIVER, t3CodeRange: ">=0.0.42", ranges: [] }], + compatibility: [CODEX_DRIVER, CURSOR_DRIVER, OPENCODE_DRIVER].map((driver) => ({ + driver, + t3CodeRange: ">=0.0.42", + ranges: [], + })), }, ) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts index 0ebaf180b51a..a500549e7f7d 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.ts +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -56,6 +56,60 @@ describe("compatibility banners", () => { ).toBeNull(); }); + it("shows downgrade guidance instead of a broken OpenCode inventory timeout", () => { + const message = "This provider version is known to be incompatible. Use 1.14.19."; + const broken: ServerProvider = { + ...provider, + driver: ProviderDriverKind.make("opencode"), + version: "2.0.3", + status: "error", + auth: { status: "unknown" }, + message: "Failed to load OpenCode provider inventory: Timed out waiting for server start.", + compatibilityAdvisory: { + status: "broken", + message, + recommendedVersion: "1.14.19", + recommendedRange: ">=1.14.19 <2.0.0", + }, + }; + expect(shouldShowProviderStatusBanner(broken, null)).toBe(true); + const timeoutOnly: ServerProvider = { + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }; + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(timeoutOnly))).toBe( + true, + ); + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(broken))).toBe(false); + expect( + shouldShowProviderStatusBanner( + { + ...broken, + compatibilityAdvisory: { ...broken.compatibilityAdvisory!, message: "Use 1.14.20." }, + }, + getProviderStatusBannerKey(broken), + ), + ).toBe(true); + expect(getProviderStatusMessage(broken)).toBe(message); + expect( + getProviderStatusMessage({ + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }), + ).toBe(broken.message); + expect(getProviderStatusMessage({ ...broken, auth: { status: "unauthenticated" } })).toBe( + broken.message, + ); + }); + it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { const unauthenticated: ServerProvider = { ...provider, diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 58d723c9a92a..c83aef7caf51 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -9,19 +9,25 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ function getIncompatibleVersion(status: ServerProvider) { const compatibility = status.compatibilityAdvisory; - return compatibility?.status === "unsupported" || compatibility?.status === "broken" + if (status.status === "error" && status.auth.status === "unauthenticated") return null; + return compatibility?.status === "broken" || + (status.status === "ready" && compatibility?.status === "unsupported") ? compatibility : null; } export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { if (!status || status.status === "disabled") return null; - if (status.status === "ready") { - const incompatible = getIncompatibleVersion(status); - return incompatible - ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") - : null; + const incompatible = getIncompatibleVersion(status); + if (incompatible) { + return [ + status.instanceId, + incompatible.status, + status.version ?? "", + incompatible.message ?? "", + ].join("\u0000"); } + if (status.status === "ready") return null; // Antigravity checks saved credentials when a session starts. Its local // health check leaves auth unknown after a restart, which is not a failure. if ( @@ -53,8 +59,15 @@ export function hasProviderSetup(status: ServerProvider): boolean { ); } -/** Keep the environment's error intact in both the banner and model picker. */ +/** Broken-version guidance takes precedence over startup failures it can cause. */ export function getProviderStatusMessage(status: ServerProvider): string { + if ( + status.auth.status !== "unauthenticated" && + status.compatibilityAdvisory?.status === "broken" && + status.compatibilityAdvisory.message + ) { + return status.compatibilityAdvisory.message; + } if (status.message) return status.message; const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); if (!status.installed && hasProviderSetup(status)) { @@ -90,14 +103,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; - const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; + const incompatible = getIncompatibleVersion(status); const title = isUnauthenticated ? `${providerName} is unauthenticated` : incompatible ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` : `${providerName} provider status`; const message = incompatible?.message ?? getProviderStatusMessage(status); - const isWarning = status.status === "warning" || incompatible !== null; + const isWarning = + incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null); return (
From d2c9281b8112dc3b2991642c4bdb985e4b08b9bb Mon Sep 17 00:00:00 2001 From: Jaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:02:24 +0200 Subject: [PATCH 12/16] fix(web): pull request badge sits on the sidebar row's baseline (#14007) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ThreadStatusIndicators.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 33e5c491229b..859650ca0a9f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -273,7 +273,9 @@ function PullRequestBadge({ className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)} > - {presentation.text} + {/* An element, not bare text: bare text takes its line box from the control, which + inherits the row's size, so beside a text-sm title it sat below the other meta. */} + {presentation.text} {presentation.label} From 022d416a88639c4eece5f4ac8a21145f623d3fb0 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:36:33 +0000 Subject: [PATCH 13/16] fix(mobile): iOS trash now removes a persisted environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Environments is a nested form sheet, so Alert.alert never presented the remove confirm. Always show an in-tree confirm, surface Keychain persist failures, and keep the catalog row if the write fails. Co-authored-by: maco --- .../src/components/ConfirmDialogHost.tsx | 131 ++++++++++-------- apps/mobile/src/connection/storage.test.ts | 56 +++++++- .../connection/ConnectionEnvironmentRow.tsx | 2 + .../state/remove-environment-confirm.test.ts | 95 +++++++++++++ .../src/state/remove-environment-confirm.ts | 82 +++++++++++ .../state/use-remote-environment-registry.ts | 51 +++---- 6 files changed, 321 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/state/remove-environment-confirm.test.ts create mode 100644 apps/mobile/src/state/remove-environment-confirm.ts diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 67e9d465c573..f14e6861dd86 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Platform, Modal, Pressable, TextInput, View } from "react-native"; +import { FullWindowOverlay } from "react-native-screens"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -15,10 +16,11 @@ type DialogRequest = let presentRequest: ((request: DialogRequest) => void) | null = null; /** - * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already - * match the app (and support per-button destructive red), so this is for - * Android, where the native dialog can only theme all confirm buttons at - * once. Requires ConfirmDialogHost to be mounted at the app root. + * Imperative confirm dialog, Alert.alert-shaped. Android uses a themed + * in-tree dialog because the native one cannot color a single destructive + * button. iOS uses a FullWindowOverlay so the confirm stays visible above + * nested form sheets — UIAlertController from the key window does not. + * Requires ConfirmDialogHost to be mounted at the app root. */ export function showConfirmDialog(request: ConfirmDialogRequest): void { presentRequest?.({ kind: "confirm", request }); @@ -81,6 +83,69 @@ export function ConfirmDialogHost() { /> ) : null; + const dialog = + presented === null ? null : ( + + + {presented.request.title} + {presented.kind === "confirm" && presented.request.message !== undefined ? ( + + {presented.request.message} + + ) : null} + {presented.kind === "text-input" ? ( + handleConfirm()} + returnKeyType="done" + selectTextOnFocus + value={inputValue} + /> + ) : null} + + + + + {presented.request.cancelText ?? "Cancel"} + + + + + handleConfirm()} + > + + {presented.request.confirmText} + + + + + + + ); + + if (Platform.OS === "ios") { + return dialog === null ? null : {dialog}; + } + return ( - {presented === null ? null : ( - - - {presented.request.title} - {presented.kind === "confirm" && presented.request.message !== undefined ? ( - - {presented.request.message} - - ) : null} - {presented.kind === "text-input" ? ( - handleConfirm()} - returnKeyType="done" - selectTextOnFocus - value={inputValue} - /> - ) : null} - - - - - {presented.request.cancelText ?? "Cancel"} - - - - - handleConfirm()} - > - - {presented.request.confirmText} - - - - - - - )} + {dialog} ); } diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 34ba6ed850a9..ef5fd66926a0 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,5 +1,8 @@ +import { BearerConnectionTarget } from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; vi.mock("react-native", () => ({ @@ -13,7 +16,10 @@ vi.mock("expo-secure-store", () => ({ })); import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; -import { MobileSecureStorage } from "../persistence/mobile-secure-storage"; +import { + MobileSecureStorage, + MobileSecureStorageError, +} from "../persistence/mobile-secure-storage"; function makeStorage(initial: Readonly>) { const values = new Map(Object.entries(initial)); @@ -94,4 +100,52 @@ describe("mobile connection catalog storage", () => { expect(memory.values.has(LEGACY_CONNECTIONS_KEY)).toBe(false); }), ); + + it.effect("keeps a saved environment when a catalog write fails", () => + Effect.gen(function* () { + const values = new Map(); + let failWrite = false; + const storage = MobileSecureStorage.of({ + getItem: (key) => Effect.sync(() => values.get(key) ?? null), + setItem: (key, value) => + Effect.gen(function* () { + if (failWrite) { + return yield* new MobileSecureStorageError({ + operation: "write", + key, + cause: new Error("keychain write failed"), + }); + } + values.set(key, value); + }), + removeItem: (key) => + Effect.sync(() => { + values.delete(key); + }), + }); + const catalog = yield* make().pipe(Effect.provideService(MobileSecureStorage, storage)); + const environmentId = EnvironmentId.make("offline-direct"); + + yield* catalog.update((document) => ({ + ...document, + targets: [ + new BearerConnectionTarget({ + environmentId, + label: "Stale Mac", + connectionId: "bearer-offline-direct", + }), + ], + })); + expect((yield* catalog.read).targets).toHaveLength(1); + + failWrite = true; + const removed = yield* catalog + .update((document) => ({ ...document, targets: [] })) + .pipe(Effect.exit); + + expect(Exit.isFailure(removed)).toBe(true); + expect((yield* catalog.read).targets).toHaveLength(1); + expect((yield* catalog.read).targets[0]?.environmentId).toBe(environmentId); + }), + ); }); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 28b34fdec6d3..41c14be706fc 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -242,6 +242,8 @@ export function ConnectionEnvironmentRow(props: { props.onRemove(props.environment.environmentId)} > diff --git a/apps/mobile/src/state/remove-environment-confirm.test.ts b/apps/mobile/src/state/remove-environment-confirm.test.ts new file mode 100644 index 000000000000..0a6ef80ecf32 --- /dev/null +++ b/apps/mobile/src/state/remove-environment-confirm.test.ts @@ -0,0 +1,95 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + presentRemoveSavedEnvironment, + REMOVE_ENVIRONMENT_CONFIRM_TITLE, + REMOVE_ENVIRONMENT_FAILURE_TITLE, +} from "./remove-environment-confirm"; + +const OFFLINE_DIRECT_ID = EnvironmentId.make("offline-direct"); + +describe("presentRemoveSavedEnvironment", () => { + it("presents a confirm for an offline direct environment even without a list label", () => { + const presentConfirm = vi.fn(); + const remove = vi.fn(); + + presentRemoveSavedEnvironment({ + environmentId: OFFLINE_DIRECT_ID, + remove, + presentConfirm, + presentError: vi.fn(), + }); + + expect(remove).not.toHaveBeenCalled(); + expect(presentConfirm).toHaveBeenCalledOnce(); + expect(presentConfirm.mock.calls[0]?.[0]).toMatchObject({ + title: REMOVE_ENVIRONMENT_CONFIRM_TITLE, + confirmText: "Remove", + destructive: true, + message: expect.stringContaining(OFFLINE_DIRECT_ID), + }); + }); + + it("says a T3 Connect environment keeps its account registration", () => { + const presentConfirm = vi.fn(); + + presentRemoveSavedEnvironment({ + environmentId: OFFLINE_DIRECT_ID, + environmentLabel: "Relay Mac", + isRelayManaged: true, + remove: vi.fn(), + presentConfirm, + presentError: vi.fn(), + }); + + expect(presentConfirm.mock.calls[0]?.[0].message).toContain("T3 Account → T3 Connect"); + }); + + it("removes the catalog target after confirm", async () => { + const presentConfirm = vi.fn(); + const presentError = vi.fn(); + const remove = vi.fn(async () => AsyncResult.success(undefined)); + + presentRemoveSavedEnvironment({ + environmentId: OFFLINE_DIRECT_ID, + environmentLabel: "Stale Mac", + remove, + presentConfirm, + presentError, + }); + + await presentConfirm.mock.calls[0]?.[0].onConfirm(); + + expect(remove).toHaveBeenCalledWith(OFFLINE_DIRECT_ID); + expect(presentError).not.toHaveBeenCalled(); + }); + + it("surfaces a persist failure and does not treat the row as removed", async () => { + const presentConfirm = vi.fn(); + const presentError = vi.fn(); + const remove = vi.fn(async () => + AsyncResult.failure( + Cause.fail(new Error("Could not save the local connection catalog: keychain write failed")), + ), + ); + + presentRemoveSavedEnvironment({ + environmentId: OFFLINE_DIRECT_ID, + environmentLabel: "Stale Mac", + remove, + presentConfirm, + presentError, + }); + + await presentConfirm.mock.calls[0]?.[0].onConfirm(); + + expect(remove).toHaveBeenCalledWith(OFFLINE_DIRECT_ID); + expect(presentError).toHaveBeenCalledWith( + REMOVE_ENVIRONMENT_FAILURE_TITLE, + "Could not save the local connection catalog: keychain write failed", + ); + }); +}); diff --git a/apps/mobile/src/state/remove-environment-confirm.ts b/apps/mobile/src/state/remove-environment-confirm.ts new file mode 100644 index 000000000000..5f194e4cb10a --- /dev/null +++ b/apps/mobile/src/state/remove-environment-confirm.ts @@ -0,0 +1,82 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; + +export const REMOVE_ENVIRONMENT_CONFIRM_TITLE = "Remove from this device?"; +export const REMOVE_ENVIRONMENT_FAILURE_TITLE = "Could not remove environment"; + +export type RemoveEnvironmentConfirmRequest = { + readonly title: string; + readonly message: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; +}; + +function resolveRemoveEnvironmentLabel( + environmentLabel: string | undefined, + environmentId: EnvironmentId, +): string { + const trimmed = environmentLabel?.trim(); + return trimmed === undefined || trimmed.length === 0 ? environmentId : trimmed; +} + +// Removing a T3 Connect environment here leaves its account registration +// and host space, so point to where it can be deregistered. +function removeEnvironmentConfirmMessage(label: string, isRelayManaged: boolean): string { + if (isRelayManaged) { + return `Forget ${label} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`; + } + return `Forget ${label} and its cached threads on this device. Switch it off instead to keep it saved.`; +} + +function removeEnvironmentFailureMessage(cause: unknown): string { + return cause instanceof Error + ? cause.message + : "The environment could not be removed from this device."; +} + +/** + * Trash on Settings → Environments lives in a nested iOS form sheet, where + * `Alert.alert` never becomes visible. Always present an in-tree confirm, + * even when the row is missing from the connected list, then surface a + * persist failure instead of leaving a Keychain-backed row that looks gone. + */ +export function presentRemoveSavedEnvironment(input: { + readonly environmentId: EnvironmentId; + readonly environmentLabel?: string; + readonly isRelayManaged?: boolean; + readonly remove: (environmentId: EnvironmentId) => Promise>; + readonly presentConfirm: (request: RemoveEnvironmentConfirmRequest) => void; + readonly presentError: (title: string, message: string) => void; +}): void { + const label = resolveRemoveEnvironmentLabel(input.environmentLabel, input.environmentId); + input.presentConfirm({ + title: REMOVE_ENVIRONMENT_CONFIRM_TITLE, + message: removeEnvironmentConfirmMessage(label, input.isRelayManaged ?? false), + confirmText: "Remove", + destructive: true, + onConfirm: () => { + void removeSavedEnvironment(input.remove, input.environmentId, input.presentError); + }, + }); +} + +async function removeSavedEnvironment( + remove: (environmentId: EnvironmentId) => Promise>, + environmentId: EnvironmentId, + presentError: (title: string, message: string) => void, +): Promise { + const result = await remove(environmentId); + if (AsyncResult.isSuccess(result) || isAtomCommandInterrupted(result)) { + return; + } + presentError( + REMOVE_ENVIRONMENT_FAILURE_TITLE, + removeEnvironmentFailureMessage(squashAtomCommandFailure(result)), + ); +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index ce309f5b06fd..7336482165d4 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,16 +1,17 @@ import { useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; +import { showConfirmDialog } from "../components/ConfirmDialogHost"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; import { useWorkspaceState } from "../state/workspace"; import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; +import { presentRemoveSavedEnvironment } from "./remove-environment-confirm"; import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; import { environmentSession } from "./session"; import { environmentCatalog } from "../connection/catalog"; @@ -112,7 +113,6 @@ export function useRemoteConnectionStatus() { export function useRemoteConnections() { const controller = useConnectionController(); - const navigation = useNavigation(); const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); @@ -170,40 +170,23 @@ export function useRemoteConnections() { const environment = connectedEnvironments.find( (candidate) => candidate.environmentId === environmentId, ); - if (!environment) { - return; - } - const remove = { - text: "Remove", - style: "destructive", - onPress: () => { - void controller.removeEnvironment(environmentId); + presentRemoveSavedEnvironment({ + environmentId, + environmentLabel: environment?.environmentLabel, + isRelayManaged: environment?.isRelayManaged ?? false, + remove: controller.removeEnvironment, + presentConfirm: showConfirmDialog, + presentError: (title, message) => { + showConfirmDialog({ + title, + message, + confirmText: "OK", + onConfirm: () => undefined, + }); }, - } as const; - // Removing a T3 Connect environment here leaves its account registration - // and host space, so point to where it can be deregistered. - if (environment.isRelayManaged) { - Alert.alert( - "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Open T3 Account", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), - }, - remove, - ], - ); - return; - } - Alert.alert( - "Remove from this device?", - `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, - [{ text: "Cancel", style: "cancel" }, remove], - ); + }); }, - [connectedEnvironments, controller, navigation], + [connectedEnvironments, controller], ); return { From 17a67ad1faca0f6a45d95e1ba1bd52c1b2344eae Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:55:58 +0000 Subject: [PATCH 14/16] docs(mobile): add JSDoc on iOS environment-trash functions CodeRabbit docstring coverage on the touched functions was 30%. Add one-line TSDoc on each helper, hook, and dialog callback in the diff. Co-authored-by: maco --- apps/mobile/src/components/ConfirmDialogHost.tsx | 3 +++ apps/mobile/src/connection/storage.test.ts | 2 ++ .../features/connection/ConnectionEnvironmentRow.tsx | 2 ++ .../src/state/remove-environment-confirm.test.ts | 5 +++++ apps/mobile/src/state/remove-environment-confirm.ts | 11 +++++++++-- .../src/state/use-remote-environment-registry.ts | 3 +++ 6 files changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index f14e6861dd86..6939fbb1cd50 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -26,6 +26,7 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { presentRequest?.({ kind: "confirm", request }); } +/** Imperative text-input dialog, Alert.prompt-shaped. Requires ConfirmDialogHost. */ export function showTextInputDialog(request: TextInputDialogRequest): void { presentRequest?.({ kind: "text-input", request }); } @@ -49,11 +50,13 @@ export function ConfirmDialogHost() { }; }, []); + /** Dismiss the presented dialog without confirming. */ const handleCancel = useCallback(() => { presented?.request.onCancel?.(); setPresented(null); }, [presented]); + /** Confirm the presented dialog, passing text-input value when present. */ const handleConfirm = useCallback( (nativeInputValue?: string) => { if (presented?.kind === "confirm") { diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index ef5fd66926a0..ba3f124ea6ac 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -21,6 +21,7 @@ import { MobileSecureStorageError, } from "../persistence/mobile-secure-storage"; +/** In-memory MobileSecureStorage double for catalog persist tests. */ function makeStorage(initial: Readonly>) { const values = new Map(Object.entries(initial)); const deleted: Array = []; @@ -101,6 +102,7 @@ describe("mobile connection catalog storage", () => { }), ); + /** Catalog update must leave the previous document when Keychain write fails. */ it.effect("keeps a saved environment when a catalog write fails", () => Effect.gen(function* () { const values = new Map(); diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 41c14be706fc..8e34a6525b31 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -21,6 +21,7 @@ import { serverEnvironment } from "../../state/server"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; +/** Compact connection status under the environment label, or Off when disabled. */ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { if (!environment.isEnabled && environment.connectionState !== "unsupported") { return "Off"; @@ -32,6 +33,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string }); } +/** One saved-environment row in Settings, including the iOS trash control. */ export function ConnectionEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; readonly expanded: boolean; diff --git a/apps/mobile/src/state/remove-environment-confirm.test.ts b/apps/mobile/src/state/remove-environment-confirm.test.ts index 0a6ef80ecf32..dc8257346751 100644 --- a/apps/mobile/src/state/remove-environment-confirm.test.ts +++ b/apps/mobile/src/state/remove-environment-confirm.test.ts @@ -11,7 +11,9 @@ import { const OFFLINE_DIRECT_ID = EnvironmentId.make("offline-direct"); +/** Confirm, remove, and persist-failure behavior for Settings → Environments trash. */ describe("presentRemoveSavedEnvironment", () => { + /** Offline rows still get a confirm even without a connected-list label. */ it("presents a confirm for an offline direct environment even without a list label", () => { const presentConfirm = vi.fn(); const remove = vi.fn(); @@ -33,6 +35,7 @@ describe("presentRemoveSavedEnvironment", () => { }); }); + /** A T3 Connect environment confirm points to where it can be deregistered. */ it("says a T3 Connect environment keeps its account registration", () => { const presentConfirm = vi.fn(); @@ -48,6 +51,7 @@ describe("presentRemoveSavedEnvironment", () => { expect(presentConfirm.mock.calls[0]?.[0].message).toContain("T3 Account → T3 Connect"); }); + /** Confirm runs the catalog remove and does not present an error on success. */ it("removes the catalog target after confirm", async () => { const presentConfirm = vi.fn(); const presentError = vi.fn(); @@ -67,6 +71,7 @@ describe("presentRemoveSavedEnvironment", () => { expect(presentError).not.toHaveBeenCalled(); }); + /** Keychain persist failure is shown and the row is not treated as removed. */ it("surfaces a persist failure and does not treat the row as removed", async () => { const presentConfirm = vi.fn(); const presentError = vi.fn(); diff --git a/apps/mobile/src/state/remove-environment-confirm.ts b/apps/mobile/src/state/remove-environment-confirm.ts index 5f194e4cb10a..4e43dc6d34a2 100644 --- a/apps/mobile/src/state/remove-environment-confirm.ts +++ b/apps/mobile/src/state/remove-environment-confirm.ts @@ -17,6 +17,7 @@ export type RemoveEnvironmentConfirmRequest = { readonly onConfirm: () => void; }; +/** Prefer a trimmed display name, otherwise the environment id. */ function resolveRemoveEnvironmentLabel( environmentLabel: string | undefined, environmentId: EnvironmentId, @@ -25,8 +26,11 @@ function resolveRemoveEnvironmentLabel( return trimmed === undefined || trimmed.length === 0 ? environmentId : trimmed; } -// Removing a T3 Connect environment here leaves its account registration -// and host space, so point to where it can be deregistered. +/** + * Body copy for the remove-from-this-device confirm. A T3 Connect environment + * keeps its account registration and host space, so point to where it can be + * deregistered. + */ function removeEnvironmentConfirmMessage(label: string, isRelayManaged: boolean): string { if (isRelayManaged) { return `Forget ${label} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`; @@ -34,6 +38,7 @@ function removeEnvironmentConfirmMessage(label: string, isRelayManaged: boolean) return `Forget ${label} and its cached threads on this device. Switch it off instead to keep it saved.`; } +/** Persist-failure copy; use the Error message when one exists. */ function removeEnvironmentFailureMessage(cause: unknown): string { return cause instanceof Error ? cause.message @@ -60,12 +65,14 @@ export function presentRemoveSavedEnvironment(input: { message: removeEnvironmentConfirmMessage(label, input.isRelayManaged ?? false), confirmText: "Remove", destructive: true, + /** After confirm, persist catalog removal and surface a Keychain write failure. */ onConfirm: () => { void removeSavedEnvironment(input.remove, input.environmentId, input.presentError); }, }); } +/** Drop the catalog row, then show an error if the Keychain write fails. */ async function removeSavedEnvironment( remove: (environmentId: EnvironmentId) => Promise>, environmentId: EnvironmentId, diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 7336482165d4..ed166171deb3 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -111,6 +111,7 @@ export function useRemoteConnectionStatus() { }; } +/** Pairing, reconnect, enable, and trash actions for saved remote environments. */ export function useRemoteConnections() { const controller = useConnectionController(); const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); @@ -165,6 +166,7 @@ export function useRemoteConnections() { [controller], ); + /** Present the in-tree remove confirm even when the environment is offline. */ const onRemoveEnvironmentPress = useCallback( (environmentId: EnvironmentId) => { const environment = connectedEnvironments.find( @@ -176,6 +178,7 @@ export function useRemoteConnections() { isRelayManaged: environment?.isRelayManaged ?? false, remove: controller.removeEnvironment, presentConfirm: showConfirmDialog, + /** Show persist failures in the same in-tree overlay as the confirm. */ presentError: (title, message) => { showConfirmDialog({ title, From 9d438cc4c954bbbdddfb43502c3e264bfafc0a3c Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:57:37 +0000 Subject: [PATCH 15/16] fix(mobile): isolate iOS confirm overlay from VoiceOver FullWindowOverlay does not mark the confirm as modal, so VoiceOver can reach controls behind a destructive dialog. Set accessibilityViewIsModal and dismiss on the VoiceOver escape gesture. Co-authored-by: maco --- apps/mobile/src/components/ConfirmDialogHost.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 6939fbb1cd50..63061383c92e 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -88,7 +88,11 @@ export function ConfirmDialogHost() { const dialog = presented === null ? null : ( - + {presented.request.title} {presented.kind === "confirm" && presented.request.message !== undefined ? ( From 8bb5037c6b995a86d9504b965fc5bad19756f57a Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:39:53 +0000 Subject: [PATCH 16/16] fix(mobile): trap VoiceOver on the iOS confirm overlay FullWindowOverlay's native container is a sibling of the React root, so accessibilityViewIsModal on the dialog View cannot hide Settings. Mark the overlay container modal and dismiss on the VoiceOver escape gesture. Co-authored-by: maco --- apps/mobile/src/components/ConfirmDialogHost.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 63061383c92e..843178d4462a 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -90,8 +90,8 @@ export function ConfirmDialogHost() { presented === null ? null : ( {presented.request.title} @@ -149,8 +149,12 @@ export function ConfirmDialogHost() { ); + // FullWindowOverlay's container is a sibling of the React root. Inner + // accessibilityViewIsModal cannot hide that sibling; the container flag can. if (Platform.OS === "ios") { - return dialog === null ? null : {dialog}; + return dialog === null ? null : ( + {dialog} + ); } return (