diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 0ee3e59b9828..ab4b574b9918 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -25,7 +25,14 @@ vi.mock("react-native", () => ({ Platform: { OS: "ios" }, })); -vi.mock("../cloud/linkEnvironment", () => ({ +vi.mock("expo-constants", () => ({ + default: { expoConfig: {} }, +})); + +vi.mock("expo-device", () => ({})); + +vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({ + ...(await importOriginal()), linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), })); @@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => { previousEnabled: true, clerkToken: "clerk-token", connections: [connection], + canConfigureEnvironment: () => true, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ @@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => { previousEnabled: false, clerkToken: "clerk-token", connections: [connection], + canConfigureEnvironment: () => true, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ @@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => { previousEnabled: true, clerkToken: null, connections: [connection], + canConfigureEnvironment: () => true, }); expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ @@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => { previousEnabled: false, clerkToken: "clerk-token", connections: [connection, managedConnection], + canConfigureEnvironment: () => true, }); expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); @@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => { previousEnabled: true, clerkToken: "clerk-token", connections: [connection], + canConfigureEnvironment: () => true, }), ); @@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => { }); }).pipe(Effect.provide(testLayer)); }); + + it.effect("only re-links environments with relay access when enabling updates", () => { + const readOnlyConnection: SavedRemoteConnection = { + ...connection, + environmentId: "read-only" as EnvironmentId, + }; + + return Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ + enabled: true, + previousEnabled: false, + clerkToken: "clerk-token", + connections: [readOnlyConnection, connection], + canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId, + }); + + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({ + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: true, + }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("keeps device updates independently switchable without relay access", () => + Effect.gen(function* () { + for (const enabled of [false, true]) { + yield* setLiveActivityUpdatesEnabled({ + enabled, + previousEnabled: !enabled, + clerkToken: "clerk-token", + connections: [connection], + canConfigureEnvironment: () => false, + }); + } + + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { + liveActivitiesEnabled: false, + }); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("checks current relay access after updating the device registration", () => { + let canConfigureEnvironment = true; + vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() => + Effect.sync(() => { + canConfigureEnvironment = false; + }), + ); + + return Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: true, + clerkToken: "clerk-token", + connections: [connection], + canConfigureEnvironment: () => canConfigureEnvironment, + }); + + expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("does not retry environment changes after relay access is revoked", () => { + let canConfigureEnvironment = true; + vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => + Effect.sync(() => { + canConfigureEnvironment = false; + }).pipe( + Effect.andThen( + Effect.fail(new CloudEnvironmentLinkError({ message: "relay access revoked" })), + ), + ), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: true, + clerkToken: "clerk-token", + connections: [connection], + canConfigureEnvironment: () => canConfigureEnvironment, + }), + ); + + expect(exit._tag).toBe("Failure"); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { + liveActivitiesEnabled: true, + }); + }).pipe(Effect.provide(testLayer)); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 1b30769e7bcd..826edaf839b9 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { SavedRemoteConnection } from "../../lib/connection"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; @@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn readonly previousEnabled: boolean; readonly clerkToken: string | null; readonly connections: ReadonlyArray; + readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean; }) { const linkedConnections = input.connections.filter( (connection) => connection.bearerToken !== null, ); + const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* ( + connection: SavedRemoteConnection, + enabled: boolean, + clerkToken: string, + ) { + if (!input.canConfigureEnvironment(connection.environmentId)) return; + + yield* linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: enabled, + }); + }); + const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { yield* updateAgentAwarenessRegistrationPreferences({ liveActivitiesEnabled: enabled, @@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn yield* Effect.forEach( linkedConnections, - (connection) => - linkEnvironmentToCloudWithPreference({ - clerkToken, - connection, - liveActivitiesEnabled: enabled, - }), + (connection) => updateEnvironmentPreference(connection, enabled, clerkToken), { concurrency: "unbounded" }, ); }); @@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn yield* Effect.forEach( linkedConnections, (connection) => - linkEnvironmentToCloudWithPreference({ - clerkToken, - connection, - liveActivitiesEnabled: input.previousEnabled, - }).pipe( + updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe( Effect.catchCause((cause) => Effect.logWarning( `Could not restore Live Activity preference for environment ${connection.environmentId}.`, diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index c2d5b6cf65ab..a90462d6f188 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -4,6 +4,7 @@ import type { } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; import { + AuthOrchestrationOperateScope, type EnvironmentId, type EnvironmentMachineKind, resolveEnvironmentMachineKind, @@ -34,6 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useServerConfigs } from "../../state/entities"; +import { useEnvironmentScope } from "../../state/session"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { createNativeMailSearchToolbarItem, @@ -416,22 +418,72 @@ function ArchivedThreadRow(props: { }) { const { width: windowWidth } = useWindowDimensions(); const cardColor = useUniwindTheme()["--color-card"]; + const canOperateThread = useEnvironmentScope( + props.thread.environmentId, + AuthOrchestrationOperateScope, + ); const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt); const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); + const rowContent = ( + + + + + + + + + {props.thread.title} + + + {timestamp} + + + {subtitle.length > 0 ? ( + + + + {subtitle.join(" · ")} + + + ) : null} + + + ); + // Keep the group's rounded corners on both interactive and read-only rows. + const containerStyle = { + borderTopLeftRadius: props.isFirst ? 20 : 0, + borderTopRightRadius: props.isFirst ? 20 : 0, + borderBottomLeftRadius: props.isLast ? 20 : 0, + borderBottomRightRadius: props.isLast ? 20 : 0, + overflow: "hidden" as const, + }; + if (!canOperateThread) return {rowContent}; + return ( - {() => ( - - - - - - - - - {props.thread.title} - - - {timestamp} - - - {subtitle.length > 0 ? ( - - - - {subtitle.join(" · ")} - - - ) : null} - - - )} + {() => rowContent} ); } diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts new file mode 100644 index 000000000000..6dc4f2534337 --- /dev/null +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -0,0 +1,309 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + AuthOrchestrationOperateScope, + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + scopes: new Map>(), + shells: [] as EnvironmentThreadShell[], + requests: [] as { + action: string; + environmentId: string; + input: { threadId: string; orderKey?: string }; + }[], + dialogs: [] as { onConfirm: () => void }[], + alerts: [] as { + title: string; + buttons?: { text: string; onPress?: () => void }[]; + }[], + afterRequest: undefined as (() => void) | undefined, +})); + +vi.mock("react", () => ({ + useCallback: (callback: unknown) => callback, + useRef: (current: unknown) => ({ current }), +})); +vi.mock("react-native", () => ({ + Alert: { + alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) => + state.alerts.push({ title, buttons }), + }, +})); +vi.mock("expo-haptics", () => ({ + impactAsync: async () => {}, + ImpactFeedbackStyle: { Light: "light" }, +})); +vi.mock("../../components/ConfirmDialogHost", () => ({ + showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog), +})); +vi.mock("../archive/useArchivedThreadSnapshots", () => ({ + refreshArchivedThreadsForEnvironment: () => {}, +})); +vi.mock("../../state/session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + state.scopes.get(environmentId)?.has(scope) === true, +})); +vi.mock("../../state/server", () => ({ + environmentServerConfigsAtom: "server-configs", +})); +vi.mock("../../state/atom-registry", () => ({ + appAtomRegistry: { + get: (atom: string) => + atom === "thread-shells" + ? state.shells + : new Map( + [...state.scopes.keys()].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { + threadSettlement: true, + threadSnooze: true, + threadPinning: true, + threadPinReorder: true, + threadTitleRegeneration: true, + }, + }, + }, + ]), + ), + }, +})); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("../../state/threads", () => ({ + environmentThreadShells: { threadShellsAtom: "thread-shells" }, + threadEnvironment: Object.fromEntries( + [ + "archive", + "unarchive", + "delete", + "settle", + "unsettle", + "snooze", + "unsnooze", + "pin", + "unpin", + "reorderPin", + "updateMetadata", + ].map((action) => [ + action, + async (request: { + environmentId: string; + input: { threadId: string; orderKey?: string }; + }) => { + state.requests.push({ action, ...request }); + if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) { + return AsyncResult.failure(Cause.fail(new Error("Thread operation denied"))); + } + state.afterRequest?.(); + return AsyncResult.success(undefined); + }, + ]), + ), +})); + +import { useArchivedThreadListActions, useThreadListActions } from "./useThreadListActions"; + +const primaryEnvironmentId = EnvironmentId.make("primary"); +const otherEnvironmentId = EnvironmentId.make("other"); + +function makeThread(input: Partial = {}): EnvironmentThreadShell { + return { + id: ThreadId.make("thread"), + title: "Thread", + environmentId: primaryEnvironmentId, + projectId: ProjectId.make("project"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +const mutationCases = [ + ["archiveThread", "archive"], + ["settleThread", "settle"], + ["unsettleThread", "unsettle"], + ["snoozeThread", "snooze"], + ["unsnoozeThread", "unsnooze"], + ["pinThread", "pin"], + ["unpinThread", "unpin"], + ["regenerateThreadTitle", "updateMetadata"], +] as const; + +beforeEach(() => { + state.scopes = new Map([ + [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])], + [otherEnvironmentId, new Set()], + ]); + state.requests = []; + state.shells = []; + state.dialogs = []; + state.alerts = []; + state.afterRequest = undefined; +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("thread list operation permissions", () => { + it.each(mutationCases)( + "%s rejects a retained callback after its environment loses permission", + async (handler) => { + const actions = useThreadListActions(); + state.scopes.get(primaryEnvironmentId)!.clear(); + + await actions[handler](makeThread(), "2099-01-01T00:00:00.000Z"); + + expect(state.requests).toEqual([]); + }, + ); + + it.each(mutationCases)("%s requires the target environment's permission", async (handler) => { + await useThreadListActions()[handler]( + makeThread({ environmentId: otherEnvironmentId }), + "2099-01-01T00:00:00.000Z", + ); + + expect(state.requests).toEqual([]); + }); + + it.each(mutationCases)( + "%s works when the target environment gains only task permission", + async (handler, action) => { + state.scopes.get(primaryEnvironmentId)!.clear(); + const actions = useThreadListActions(); + state.scopes.get(otherEnvironmentId)!.add(AuthOrchestrationOperateScope); + + await actions[handler]( + makeThread({ environmentId: otherEnvironmentId }), + "2099-01-01T00:00:00.000Z", + ); + + expect(state.requests).toEqual([ + expect.objectContaining({ action, environmentId: otherEnvironmentId }), + ]); + }, + ); + + it.each(["ios", "android"])("does not show a forbidden %s delete confirmation", (os) => { + vi.stubEnv("EXPO_OS", os); + useThreadListActions().confirmDeleteThread(makeThread({ environmentId: otherEnvironmentId })); + + expect(state.dialogs).toEqual([]); + expect(state.alerts.some((alert) => alert.title === "Delete thread?")).toBe(false); + expect(state.requests).toEqual([]); + }); + + it.each(["ios", "android"])( + "rechecks permission after a retained %s delete confirmation", + async (os) => { + vi.stubEnv("EXPO_OS", os); + useThreadListActions().confirmDeleteThread(makeThread()); + const confirm = + os === "ios" + ? state.alerts[0]?.buttons?.find((button) => button.text === "Delete")?.onPress + : state.dialogs[0]?.onConfirm; + expect(confirm).toBeTypeOf("function"); + state.scopes.get(primaryEnvironmentId)!.clear(); + + await confirm!(); + + expect(state.requests).toEqual([]); + }, + ); + + it("unarchives with only task permission and blocks a later revoked callback", async () => { + const actions = useArchivedThreadListActions(() => {}); + const thread = makeThread({ archivedAt: "2026-09-02T00:00:00.000Z" }); + await actions.unarchiveThread(thread); + expect(state.requests).toEqual([expect.objectContaining({ action: "unarchive" })]); + state.requests = []; + state.scopes.get(primaryEnvironmentId)!.clear(); + + await actions.unarchiveThread(thread); + + expect(state.requests).toEqual([]); + }); + + it("keeps delete independent of terminal and source-control permissions", async () => { + vi.stubEnv("EXPO_OS", "android"); + useArchivedThreadListActions(() => {}).confirmDeleteThread(makeThread()); + await state.dialogs[0]!.onConfirm(); + + expect(state.requests).toEqual([expect.objectContaining({ action: "delete" })]); + }); +}); + +describe("pinned thread operation permissions", () => { + it("checks every materialization target before writing any keys", async () => { + const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("other-thread"), + environmentId: otherEnvironmentId, + pinnedAt: "2026-09-02T00:00:00.000Z", + createdAt: "2026-09-02T00:00:00.000Z", + }), + ]; + + expect(await useThreadListActions().movePinnedThread(moved, "up")).toBe(false); + expect(state.requests).toEqual([]); + }); + + it("moves a keyed thread past a read-only neighbor without writing that neighbor", async () => { + const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", pinOrderKey: "m" }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("other-thread"), + environmentId: otherEnvironmentId, + pinnedAt: "2026-09-02T00:00:00.000Z", + pinOrderKey: "g", + }), + ]; + + expect(await useThreadListActions().movePinnedThread(moved, "up")).toBe(true); + expect(state.requests).toEqual([ + expect.objectContaining({ action: "reorderPin", environmentId: primaryEnvironmentId }), + ]); + }); + + it("rechecks permission between materialization writes", async () => { + const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" }); + state.shells = [ + moved, + makeThread({ + id: ThreadId.make("other-thread"), + pinnedAt: "2026-09-02T00:00:00.000Z", + createdAt: "2026-09-02T00:00:00.000Z", + }), + ]; + state.afterRequest = () => state.scopes.get(primaryEnvironmentId)!.clear(); + + expect(await useThreadListActions().movePinnedThread(moved, "up")).toBe(false); + expect(state.requests).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dae6c46a89dd..8f9a30871d41 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -15,6 +16,7 @@ import { } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -87,6 +89,12 @@ function actionFailureTitle(action: ThreadListAction): string { return "Could not delete thread"; } +function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean { + if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true; + Alert.alert(title, "This connection cannot change threads."); + return false; +} + /** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, @@ -100,6 +108,7 @@ function useThreadActionExecutor( const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { return false; @@ -184,6 +193,7 @@ function useConfirmDeleteThread( ) { return useCallback( (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return; const title = "Delete thread?"; const message = `“${thread.title}” will be permanently deleted, including its terminal history.`; if (process.env.EXPO_OS === "ios") { @@ -251,6 +261,7 @@ export function useThreadListActions(): { ); const snoozeThread = useCallback( async (thread: EnvironmentThreadShell, snoozedUntil: string) => { + if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; @@ -301,6 +312,7 @@ export function useThreadListActions(): { ); const unsnoozeThread = useCallback( async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if (snoozeInFlightThreadKeys.current.has(key)) { return false; @@ -343,6 +355,7 @@ export function useThreadListActions(): { ); const pinThread = useCallback( async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false; if (!environmentSupportsPinning(thread.environmentId)) { Alert.alert( "Could not pin thread", @@ -383,6 +396,7 @@ export function useThreadListActions(): { ); const unpinThread = useCallback( async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false; if (!environmentSupportsPinning(thread.environmentId)) { Alert.alert( "Could not unpin thread", @@ -411,6 +425,7 @@ export function useThreadListActions(): { ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { + if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false; const key = scopedThreadKey(thread.environmentId, thread.id); if ( thread.titleRegeneration != null || @@ -465,6 +480,7 @@ export function useThreadListActions(): { const movePinnedInFlightRef = useRef(false); const movePinnedThread = useCallback( async (thread: EnvironmentThreadShell, direction: "up" | "down") => { + if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (movePinnedInFlightRef.current) return false; if (!environmentSupportsPinReorder(thread.environmentId)) { Alert.alert( @@ -498,12 +514,18 @@ export function useThreadListActions(): { const shellByKey = new Map( pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); + for (const assignment of assignments) { + const target = shellByKey.get(assignment.id); + if (target && !checkThreadOperationPermission(target, "Could not move thread")) + return false; + } selectionHaptic(); movePinnedInFlightRef.current = true; try { for (const assignment of assignments) { const target = shellByKey.get(assignment.id); if (target === undefined) continue; + if (!checkThreadOperationPermission(target, "Could not move thread")) return false; const result = await reorderPinnedMutation({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts index aec583d67f73..7061b0795ae8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -1,6 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { + resolveAgentAwarenessPlatformPresentation, + resolveAutoSettleReferenceEnvironmentId, +} from "./SettingsRouteScreen.logic"; + +describe("resolveAutoSettleReferenceEnvironmentId", () => { + const firstId = EnvironmentId.make("first"); + const secondId = EnvironmentId.make("second"); + + it("waits for an earlier grant before exposing a later writable reference", () => { + const second = { environmentId: secondId, canWriteSettings: true }; + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: null }, + second, + ]), + ).toBeNull(); + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: false }, + second, + ]), + ).toBe(secondId); + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: true }, + second, + ]), + ).toBe(firstId); + }); + + it("does not wait for later grants after finding the first writable reference", () => { + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: true }, + { environmentId: secondId, canWriteSettings: null }, + ]), + ).toBe(firstId); + }); + + it("waits before showing a read-only fallback until all grants are resolved", () => { + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: false }, + { environmentId: secondId, canWriteSettings: null }, + ]), + ).toBeNull(); + expect( + resolveAutoSettleReferenceEnvironmentId([ + { environmentId: firstId, canWriteSettings: false }, + { environmentId: secondId, canWriteSettings: false }, + ]), + ).toBe(firstId); + }); + + it("has no reference when no environment supports synchronization", () => { + expect(resolveAutoSettleReferenceEnvironmentId([])).toBeNull(); + }); +}); describe("resolveAgentAwarenessPlatformPresentation", () => { it("explains that agent awareness settings are unavailable on Android", () => { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts index 94fa5965e994..08ae47d073fa 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -1,3 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** Wait for earlier grants before choosing the settings that edits and synchronization use. */ +export function resolveAutoSettleReferenceEnvironmentId( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly canWriteSettings: boolean | null; + }>, +): EnvironmentId | null { + for (const environment of environments) { + if (environment.canWriteSettings === null) return null; + if (environment.canWriteSettings) return environment.environmentId; + } + return environments[0]?.environmentId ?? null; +} + export function resolveAgentAwarenessPlatformPresentation(platform: string): { readonly supported: boolean; readonly subtitle: string | undefined; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3bd25a20c8da..75827cf54893 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -35,9 +35,12 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; +import { environmentSession, readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; import { + AuthRelayWriteScope, + AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, @@ -60,7 +63,10 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; -import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { + resolveAgentAwarenessPlatformPresentation, + resolveAutoSettleReferenceEnvironmentId, +} from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -325,6 +331,8 @@ function ConfiguredSettingsRouteScreen() { previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, + canConfigureEnvironment: (environmentId) => + readEnvironmentScope(environmentId, AuthRelayWriteScope), }), ), ); @@ -350,7 +358,7 @@ function ConfiguredSettingsRouteScreen() { Alert.alert( "Live Activities enabled", environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` + ? "Live Activity updates are enabled for this device." : "Live Activity updates are enabled. Add an environment to start receiving updates.", ); } else { @@ -414,6 +422,8 @@ function ConfiguredSettingsRouteScreen() { previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, + canConfigureEnvironment: (environmentId) => + readEnvironmentScope(environmentId, AuthRelayWriteScope), }), ), ); @@ -555,9 +565,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD /** * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first eligible sync target provides the - * reference value. Edits fan out to every eligible target, and a mismatch row - * lets the user push the reference out. + * has no primary environment, so the first writable sync target provides the + * reference value. Edits fan out to writable targets, and a mismatch row lets + * the user push the reference out. Read-only connections still show settings. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -566,18 +576,54 @@ function AutoSettleSettingsRows() { reportFailure: true, }); - const syncTargets = environments.filter(supportsSharedSettingsSync); - const reference = syncTargets[0] ?? null; + const settingsAccessAtom = useMemo( + () => + Atom.make((get) => + environments.filter(supportsSharedSettingsSync).map((environment) => { + const result = get(environmentSession.sessionStateAtom(environment.environmentId)); + const session = result._tag === "Success" ? result.value : null; + return { + environmentId: environment.environmentId, + canWriteSettings: + result._tag === "Initial" + ? null + : session?.authenticated === true && + session.scopes?.includes(AuthSettingsWriteScope) === true, + }; + }), + ), + [environments], + ); + const settingsAccess = useAtomValue(settingsAccessAtom); + const writableEnvironmentIds = new Set( + settingsAccess.flatMap((environment) => + environment.canWriteSettings ? [environment.environmentId] : [], + ), + ); + const availableTargets = environments.filter(supportsSharedSettingsSync); + const syncTargets = availableTargets.filter((environment) => + writableEnvironmentIds.has(environment.environmentId), + ); + const canWriteSettings = syncTargets.length > 0; + const referenceEnvironmentId = resolveAutoSettleReferenceEnvironmentId(settingsAccess); + const reference = + availableTargets.find((environment) => environment.environmentId === referenceEnvironmentId) ?? + null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); if (reference === null || referenceSettings === null) { - return null; + return availableTargets.length > 0 ? ( + + Loading auto-settle settings… + + ) : null; } const writeToAll = (patch: ServerSettingsPatch) => { for (const environment of syncTargets) { + if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) continue; void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; @@ -589,7 +635,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), + syncEligible: writableEnvironmentIds.has(environment.environmentId), settings: environment.serverConfig?.settings ?? null, capabilities: environment.serverConfig?.environment.capabilities, })), @@ -615,12 +661,14 @@ function AutoSettleSettingsRows() { return ( <> writeToAll({ sidebarAutoSettleOnMerge: value })} /> Days before auto-settle ) : null} + {syncTargets.length < availableTargets.length ? ( + + + {canWriteSettings + ? "Changes apply only to environments this connection can configure." + : "This connection cannot change environment settings."} + + + ) : null} {mismatches.length > 0 ? ( @@ -660,6 +718,7 @@ function AutoSettleSettingsRows() { reference.serverConfig?.environment.capabilities, ); for (const mismatch of mismatches) { + if (!readEnvironmentScope(mismatch.environmentId, AuthSettingsWriteScope)) continue; const target = environments.find( (candidate) => candidate.environmentId === mismatch.environmentId, ); @@ -676,7 +735,9 @@ function AutoSettleSettingsRows() { }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + {syncTargets.length < availableTargets.length ? "Apply settings" : "Apply to all"} + ) : null} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e1cc7405bde2..f079a7100971 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -25,6 +25,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + AuthOrchestrationOperateScope, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; @@ -106,6 +107,7 @@ import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-share-model"; import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -177,6 +179,12 @@ export function NewTaskDraftScreen(props: { connectedEnvironments.find( (environment) => environment.environmentId === selectedProject.environmentId, )?.connectionState === "connected"; + const canOperate = useEnvironmentScope( + selectedProject?.environmentId ?? null, + AuthOrchestrationOperateScope, + ); + const taskPermissionReason = + environmentConnected && !canOperate ? "This connection cannot start tasks." : null; const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true; const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentBlockReason = selectedProject @@ -867,6 +875,12 @@ export function NewTaskDraftScreen(props: { if (!selectedProject || !draftKey) { return; } + if ( + environmentConnected && + !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope) + ) { + return; + } const draft = getComposerDraftSnapshot(draftKey); // Read the latest explicit pick. Antigravity selections stay unchanged // when setup or a catalog change makes them unavailable. @@ -1055,6 +1069,7 @@ export function NewTaskDraftScreen(props: { const isAndroid = Platform.OS === "android"; const canStart = + taskPermissionReason === null && attachmentBlockReason === null && !modelUnavailable && Boolean(flow.selectedProject) && @@ -1250,6 +1265,10 @@ export function NewTaskDraftScreen(props: { ) : null} {workspaceControls} + {taskPermissionReason ? ( + {taskPermissionReason} + ) : null} + {modelUnavailable ? ( void props.onRespond(props.approval.requestId, option.decision)} > ))} + {!props.canOperateThread ? ( + + This connection cannot respond to approvals. + + ) : null} ); } diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index f821c5714950..9b8a62fd2d67 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -25,6 +25,7 @@ import { } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { + readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; /** * Constant while a request is pending (it reserves keyboard space), so the @@ -191,6 +192,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { icon="stop.fill" variant="danger" className="h-9 w-9" + disabled={!props.canOperateThread} onPress={props.onStopThread} /> ) : null} @@ -328,15 +330,24 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { void props.onSubmit()} > Submit answers + {!props.canOperateThread ? ( + + This connection cannot submit answers. + + ) : null} ) : null; return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..489b8c9b2a04 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -102,6 +102,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60; export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { + readonly canOperateThread: boolean; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; readonly placeholder: string; @@ -376,6 +377,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer states: uploadStates, }); const canSend = + (props.canOperateThread || props.connectionState !== "connected") && hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null && @@ -722,6 +724,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer accessibilityLabel="Stop agent" icon="stop.fill" variant="danger" + disabled={!props.canOperateThread} onPress={props.onStopThread} /> ) : ( @@ -813,6 +816,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer accessibilityLabel="Stop agent" icon="stop.fill" variant="danger" + disabled={!props.canOperateThread} onPress={props.onStopThread} /> ) : voicePresentation.showsSend ? ( @@ -830,12 +834,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer + {!props.canOperateThread ? ( + + This connection cannot control this task. You can still edit your draft. + + ) : null} {/* Queue count */} {props.queueCount > 0 ? ( - {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send - automatically. + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"}{" "} + {props.canOperateThread ? "will send automatically." : "paused until access returns."} ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..a88998648e81 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -93,6 +93,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { + readonly canOperateThread: boolean; readonly selectedThread: OrchestrationThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; @@ -793,6 +794,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > {props.activePendingApproval ? ( { if ( !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) || (selectedThread.session?.status !== "running" && selectedThread.session?.status !== "starting") ) { @@ -768,6 +779,7 @@ function ThreadRouteContent( void) => compact ? ( ); + if (!canOperateThread) return rowContent(() => {}); + return ( onDeleteThread(thread), [onDeleteThread, thread]); + const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -667,8 +669,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : null, [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); - const swipeAccessibilityHint = - secondaryAction === null + const swipeAccessibilityHint = !canOperateThread + ? "Opens the thread" + : secondaryAction === null ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; @@ -932,6 +935,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); + if (!canOperateThread) return rowContent(() => {}); + return ( <> ({ + allowed: new Set(), + start: vi.fn(), + prepare: vi.fn(), + reportError: vi.fn(), + cleanup: vi.fn(), + readScope: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("../../state/session", () => ({ readEnvironmentScope: state.readScope })); +vi.mock("../../state/threads", () => ({ threadEnvironment: { startTurn: {} } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.start })); +vi.mock("../../state/use-remote-environment-registry", () => ({ + setPendingConnectionError: state.reportError, +})); +vi.mock("../../state/use-composer-drafts", () => ({ + scheduleUnusedComposerAttachmentCleanup: state.cleanup, +})); +vi.mock("../../lib/attachmentUpload", () => ({ + prepareTurnAttachments: state.prepare, + validateDraftFileAttachments: () => null, +})); +vi.mock("../../lib/uuid", () => ({ randomHex: () => "abcdef", uuidv4: () => "unused" })); +vi.mock("../../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { configValueAtom: (environmentId: string) => environmentId }, +})); +vi.mock("../../state/atom-registry", () => ({ + appAtomRegistry: { get: () => ({ providers: [], environment: { capabilities: {} } }) }, +})); + +import { useCreateProjectThread } from "./use-project-actions"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +const project: EnvironmentProject = { + environmentId: secondary, + id: ProjectId.make("project"), + title: "Test project", + workspaceRoot: "/work/project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-05T00:00:00Z", + updatedAt: "2026-09-05T00:00:00Z", +}; + +function input() { + return { + project, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, + envMode: "local" as const, + branch: "main", + worktreePath: null, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + initialMessageText: "Check the fixture", + initialAttachments: [], + onAttachmentsUploaded: vi.fn().mockResolvedValue(undefined), + turnMetadata: { + commandId: "command", + threadId: "thread", + messageId: "message", + createdAt: "2026-09-05T00:00:00Z", + }, + }; +} + +beforeEach(() => { + state.allowed.clear(); + state.start.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.prepare.mockReset().mockResolvedValue({ + status: "ready", + attachments: [], + draftAttachments: [], + }); + state.reportError.mockReset(); + state.cleanup.mockReset(); + state.readScope + .mockReset() + .mockImplementation( + (environmentId, scope) => + scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId), + ); +}); + +describe("new task permissions", () => { + it("does not upload or start a task using another environment's grant", async () => { + state.allowed.add(primary); + const create = useCreateProjectThread(); + + const result = await create(input()); + expect(result._tag).toBe("Failure"); + if (!AsyncResult.isFailure(result)) throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.prepare).not.toHaveBeenCalled(); + expect(state.start).not.toHaveBeenCalled(); + expect(state.reportError).toHaveBeenCalledWith("This connection cannot start tasks."); + }); + + it("allows the selected environment to start a task without a primary grant", async () => { + state.allowed.add(secondary); + const create = useCreateProjectThread(); + + expect(await create(input())).toMatchObject({ + _tag: "Success", + value: { environmentId: secondary, threadId: "thread" }, + }); + expect(state.start).toHaveBeenCalledExactlyOnceWith({ + environmentId: secondary, + input: expect.objectContaining({ threadId: "thread" }), + }); + expect(state.cleanup).toHaveBeenCalledTimes(1); + }); + + it("rechecks access after attachments finish preparing and preserves the draft", async () => { + state.allowed.add(secondary); + state.prepare.mockImplementationOnce(async () => { + state.allowed.delete(secondary); + return { status: "ready", attachments: [], draftAttachments: [] }; + }); + const create = useCreateProjectThread(); + + const result = await create(input()); + expect(result._tag).toBe("Failure"); + if (!AsyncResult.isFailure(result)) throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.prepare).toHaveBeenCalledTimes(1); + expect(state.start).not.toHaveBeenCalled(); + expect(state.cleanup).not.toHaveBeenCalled(); + }); + + it("a retained start callback follows revoked and newly granted access", async () => { + state.allowed.add(secondary); + const create = useCreateProjectThread(); + state.allowed.delete(secondary); + + const result = await create(input()); + expect(result._tag).toBe("Failure"); + if (!AsyncResult.isFailure(result)) throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.prepare).not.toHaveBeenCalled(); + state.allowed.add(secondary); + expect((await create(input()))._tag).toBe("Success"); + expect(state.start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index bb0dce57ff71..75e9bd1d5ce2 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -4,6 +4,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, ThreadId, type ModelSelection, type ProviderInteractionMode, @@ -26,6 +28,7 @@ import { setPendingConnectionError } from "../../state/use-remote-environment-re import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; import { resolveProviderInteractionMode } from "./legacy-plan-mode"; export function useCreateProjectThread() { @@ -49,6 +52,14 @@ export function useCreateProjectThread() { /** Reuse identifiers from a queued pending task instead of minting new ones. */ readonly turnMetadata?: TurnCommandMetadata; }) => { + if (!readEnvironmentScope(input.project.environmentId, AuthOrchestrationOperateScope)) { + const error = new EnvironmentAuthorizationError({ + message: "This connection cannot start tasks.", + requiredScope: AuthOrchestrationOperateScope, + }); + setPendingConnectionError(error.message); + return AsyncResult.failure(Cause.fail(error)); + } const metadata = input.turnMetadata ?? makeTurnCommandMetadata(); const threadId = ThreadId.make(metadata.threadId); const initialMessageText = input.initialMessageText.trim(); @@ -128,6 +139,14 @@ export function useCreateProjectThread() { (candidate) => candidate.instanceId === input.modelSelection.instanceId, ); + if (!readEnvironmentScope(input.project.environmentId, AuthOrchestrationOperateScope)) { + const error = new EnvironmentAuthorizationError({ + message: "This connection cannot start tasks.", + requiredScope: AuthOrchestrationOperateScope, + }); + setPendingConnectionError(error.message); + return AsyncResult.failure(Cause.fail(error)); + } const result = await startTurn({ environmentId: input.project.environmentId, input: buildProjectThreadStartTurnInput({ diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..84716d5f46f0 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -1,13 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - ProviderConsumeResetCreditOutcome, - ProviderInstanceId, - ServerProvider, - ServerProviderResetCredits, - ServerProviderUsageWindow, - UsageLimitSourceAccount, - UsageProviderKind, +import { + AuthProvidersManageScope, + type EnvironmentId, + type ProviderConsumeResetCreditOutcome, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderResetCredits, + type ServerProviderUsageWindow, + type UsageLimitSourceAccount, + type UsageProviderKind, } from "@t3tools/contracts"; import { collectLimitSources, @@ -26,6 +27,7 @@ import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { useProviderColors } from "./usageProviders"; @@ -164,6 +166,7 @@ function ResetCredits(props: { readonly now: number; }) { const { environmentId, instanceId, credits, now } = props; + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -182,6 +185,7 @@ function ResetCredits(props: { }`; const redeem = async () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); const result = await consume({ environmentId, input: { instanceId } }); @@ -198,6 +202,7 @@ function ResetCredits(props: { }; const confirm = () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; Alert.alert( "Use a reset credit?", "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", @@ -214,16 +219,21 @@ function ResetCredits(props: { {credits.availableCount > 0 ? ( {busy ? "Using credit…" : "Use a reset credit"} ) : null} + {!canManageProviders ? ( + + This connection cannot manage provider accounts. + + ) : null} {status ? {status} : null} ); diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index c7289d8cb693..7e1807d3e803 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ + canOperate: true, documentUri: "file:///documents", createAssetUrl: vi.fn(), createUploadUrl: Symbol("create-upload-url"), @@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({ })); vi.mock("../state/session", () => ({ + readEnvironmentScope: () => mocks.canOperate, environmentSession: { preparedConnectionValueAtom: () => mocks.preparedConnection, }, @@ -184,6 +186,7 @@ function removeCallsFor(attachmentId: string): number { describe("prepareTurnAttachments", () => { beforeEach(() => { + mocks.canOperate = true; mocks.documentUri = "file:///documents"; mocks.createAssetUrl.mockReset(); mocks.createAssetUrl.mockImplementation((target: unknown) => target); @@ -212,6 +215,80 @@ describe("prepareTurnAttachments", () => { mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} }); }); + it("does not mint or transfer attachments without task operation access", async () => { + mocks.canOperate = false; + await expect( + prepareTurnAttachments({ + environmentId, + attachments: [fileBackedImage], + supportsImageUploads: true, + }), + ).rejects.toThrow("cannot upload attachments"); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("rechecks access after a signed URL is minted before sending any bytes", async () => { + mocks.runAtomCommand.mockImplementationOnce(async () => { + mocks.canOperate = false; + return { + _tag: "Success", + value: { + attachmentId: MINTED_ID, + relativeUrl: "/api/attachments/upload/signed", + expiresAt: 1, + }, + }; + }); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect( + prepareTurnAttachments({ + environmentId, + attachments: [fileBackedImage], + supportsImageUploads: true, + }), + ).rejects.toThrow("cannot upload attachments"); + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); + } finally { + warning.mockRestore(); + } + }); + + it("does not replace an expired upload when access was revoked during verification", async () => { + mocks.executeAtomQuery.mockImplementationOnce(async () => { + mocks.canOperate = false; + return { _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } }; + }); + await expect( + prepareTurnAttachments({ + environmentId, + attachments: [ + { + ...fileBackedImage, + uploadedAttachmentId: MINTED_ID, + uploadEnvironmentId: environmentId, + }, + ], + supportsImageUploads: true, + }), + ).rejects.toThrow("cannot upload attachments"); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("rechecks deletion permission before retrying a failed cleanup", async () => { + mocks.runAtomCommand.mockImplementationOnce(async () => { + mocks.canOperate = false; + return { _tag: "Failure", error: new Error("Retry cleanup") }; + }); + await expect(releasePendingAttachmentUploads(environmentId, [MINTED_ID])).rejects.toThrow( + "cannot delete pending attachments", + ); + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); + }); + it("keeps existing image attachments on the legacy wire path", async () => { const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index b6b5e3a8d635..cf101324a843 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -13,13 +13,16 @@ import type { EnvironmentId, UploadChatImageAttachment, } from "@t3tools/contracts"; -import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; -import { environmentSession } from "../state/session"; +import { environmentSession, readEnvironmentScope } from "../state/session"; import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { @@ -103,6 +106,9 @@ export async function releasePendingAttachmentUploads( attachmentIds: ReadonlyArray, ): Promise { const deleteOnce = async (attachmentId: string): Promise => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + throw new Error("This connection cannot delete pending attachments."); + } const result = await runAtomCommand( appAtomRegistry, attachmentEnvironment.remove, @@ -225,6 +231,7 @@ async function composerImageAttachmentDataUrl( } async function uploadFileBytes( + environmentId: EnvironmentId, attachment: DraftComposerAttachment, url: string, signal: AbortSignal, @@ -232,6 +239,9 @@ async function uploadFileBytes( ): Promise { const { File, Paths, UploadType } = await import("expo-file-system"); if (signal.aborted) throw new Error("Upload cancelled."); + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + throw new Error("This connection cannot upload attachments."); + } // Legacy image drafts persisted inline bytes and stage them in a temp cache // file for the native uploader. Everything else uploads its owned copy. const fileUri = attachment.fileUri; @@ -319,6 +329,13 @@ export async function prepareTurnAttachments(input: { } } + const requireUploadAccess = () => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + throw new Error("This connection cannot upload attachments."); + } + }; + requireUploadAccess(); + const connection = appAtomRegistry.get( environmentSession.preparedConnectionValueAtom(environmentId), ); @@ -335,6 +352,7 @@ export async function prepareTurnAttachments(input: { try { for (const attachment of input.attachments) { if (controller.signal.aborted) throw new Error("Upload cancelled."); + requireUploadAccess(); if (attachment.type === "image" && !input.supportsImageUploads) { uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment]))); continue; @@ -363,6 +381,7 @@ export async function prepareTurnAttachments(input: { // "missing": the pending upload expired, upload the bytes again. } + requireUploadAccess(); const result = await runAttachmentUploadCycle({ registry: appAtomRegistry, createUploadUrl: attachmentEnvironment.createUploadUrl, @@ -372,6 +391,7 @@ export async function prepareTurnAttachments(input: { // Read the connection at transfer time: the environment may have // reconnected on a new base URL since this cycle started. resolveUploadUrl: (relativeUrl) => { + requireUploadAccess(); const currentConnection = appAtomRegistry.get( environmentSession.preparedConnectionValueAtom(environmentId), ); @@ -381,6 +401,7 @@ export async function prepareTurnAttachments(input: { }, transport: (url) => ({ done: uploadFileBytes( + environmentId, attachment, url, controller.signal, diff --git a/apps/mobile/src/state/attachments.test.ts b/apps/mobile/src/state/attachments.test.ts new file mode 100644 index 000000000000..6e363390601a --- /dev/null +++ b/apps/mobile/src/state/attachments.test.ts @@ -0,0 +1,99 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, + EnvironmentId, +} from "@t3tools/contracts"; +import { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments"; +import * as Cause from "effect/Cause"; +import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + grantedEnvironments: new Set(), + create: vi.fn(), + remove: vi.fn(), +})); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), +})); +vi.mock("@t3tools/client-runtime/state/attachments", async (importOriginal) => ({ + ...(await importOriginal()), + createAttachmentEnvironmentAtoms: () => ({ + createUploadUrl: { label: "create", run: state.create }, + remove: { label: "remove", run: state.remove }, + }), +})); + +import { attachmentEnvironment } from "./attachments"; + +const environmentId = EnvironmentId.make("secondary"); +const registry = AtomRegistry.make(); +beforeEach(() => { + state.grantedEnvironments = new Set(["primary"]); + state.create.mockReset().mockResolvedValue( + AsyncResult.success({ + attachmentId: "pending", + relativeUrl: "/upload", + expiresAt: 1, + }), + ); + state.remove.mockReset().mockResolvedValue(AsyncResult.success(undefined)); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("attachment mutation grants", () => { + it("uses the attachment environment for both mint and deletion", async () => { + const mintResult = await attachmentEnvironment.createUploadUrl.run(registry, { + environmentId, + input: { name: "image.png", mimeType: "image/png", sizeBytes: 1 }, + }); + const removeResult = await attachmentEnvironment.remove.run(registry, { + environmentId, + input: { attachmentId: "pending" }, + }); + for (const result of [mintResult, removeResult]) { + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected permission denial"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + } + expect(state.create).not.toHaveBeenCalled(); + expect(state.remove).not.toHaveBeenCalled(); + state.grantedEnvironments.add("secondary"); + expect( + ( + await attachmentEnvironment.remove.run(registry, { + environmentId, + input: { attachmentId: "pending" }, + }) + )._tag, + ).toBe("Success"); + expect(state.remove).toHaveBeenCalledOnce(); + }); + + it("blocks the upload cycle's asynchronous cleanup after grant revocation", async () => { + state.grantedEnvironments.add("secondary"); + const transport = vi.fn(); + const result = await runAttachmentUploadCycle({ + registry, + ...attachmentEnvironment, + environmentId, + upload: { name: "image.png", mimeType: "image/png", sizeBytes: 1 }, + resolveUploadUrl: () => "https://example.test/upload", + transport, + onMinted: () => { + state.grantedEnvironments.delete("secondary"); + return "cancel"; + }, + }); + expect(result.status).toBe("cancelled"); + expect(state.create).toHaveBeenCalledOnce(); + expect(state.remove).not.toHaveBeenCalled(); + expect(transport).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/state/attachments.ts b/apps/mobile/src/state/attachments.ts index 3377a96c1ecf..1f9412fa83f4 100644 --- a/apps/mobile/src/state/attachments.ts +++ b/apps/mobile/src/state/attachments.ts @@ -1,5 +1,39 @@ import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments"; +import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, + type EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; +import { readEnvironmentScope } from "./session"; -export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime); +function requireAttachmentWriteAccess( + command: AtomCommand, +): AtomCommand { + return { + ...command, + run: async (registry, input) => { + if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + message: "This connection cannot change attachments.", + requiredScope: AuthOrchestrationOperateScope, + }), + ), + ); + } + return command.run(registry, input); + }, + }; +} + +const commands = createAttachmentEnvironmentAtoms(connectionAtomRuntime); +export const attachmentEnvironment = { + createUploadUrl: requireAttachmentWriteAccess(commands.createUploadUrl), + remove: requireAttachmentWriteAccess(commands.remove), +}; diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index ad38551d5915..e809e6e6b74e 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; @@ -24,6 +24,7 @@ import { setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; export { composerAttachmentUploadBlockReason } from "../lib/composerAttachmentUploadQueue"; @@ -45,6 +46,7 @@ export function useComposerAttachmentUploadState( } export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; uploadQueue?.retry(environmentId, attachmentId); } @@ -54,6 +56,10 @@ export function useComposerAttachmentUploadWorker() { const queuedMessages = useThreadOutboxMessages(); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); + const operableEnvironments = useEnvironmentsWithScope( + connectedEnvironments, + AuthOrchestrationOperateScope, + ); const queueRef = useRef | null>(null); useEffect(() => { @@ -114,7 +120,12 @@ export function useComposerAttachmentUploadWorker() { ); const requests = Object.entries(drafts).flatMap(([key, draft]) => { const environmentId = composerDraftEnvironmentId(key, queued); - if (environmentId === null || !connected.has(environmentId)) return []; + if ( + environmentId === null || + !connected.has(environmentId) || + !operableEnvironments.has(environmentId) + ) + return []; return draft.attachments .filter((attachment) => canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)), @@ -122,5 +133,5 @@ export function useComposerAttachmentUploadWorker() { .map((attachment) => ({ environmentId, attachment })); }); queueRef.current?.sync(requests); - }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]); + }, [connectedEnvironments, drafts, operableEnvironments, queuedMessages, serverConfigs]); } diff --git a/apps/mobile/src/state/session.test.ts b/apps/mobile/src/state/session.test.ts new file mode 100644 index 000000000000..e84c4086fc22 --- /dev/null +++ b/apps/mobile/src/state/session.test.ts @@ -0,0 +1,100 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + type AuthSessionState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ lastAtom: null as Atom.Atom | null })); +vi.mock("react", () => ({ useMemo: (factory: () => A) => factory() })); +vi.mock("@effect/atom-react", async () => { + const { appAtomRegistry } = await import("./atom-registry"); + return { + useAtomValue: (atom: Atom.Atom) => { + harness.lastAtom = atom; + return appAtomRegistry.get(atom); + }, + }; +}); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("@t3tools/client-runtime/state/session", () => ({ + createEnvironmentSessionAtoms: () => ({ + sessionStateAtom: Atom.family((_id: EnvironmentId) => + Atom.make(AsyncResult.initial()).pipe(Atom.keepAlive), + ), + }), +})); + +import { appAtomRegistry } from "./atom-registry"; +import { + environmentSession, + readEnvironmentScope, + useEnvironmentScope, + useEnvironmentsWithScope, +} from "./session"; + +const primary = EnvironmentId.make("primary"); +const secondary = EnvironmentId.make("secondary"); +const session = (canOperate: boolean): AuthSessionState => ({ + authenticated: true, + scopes: canOperate ? [AuthOrchestrationOperateScope] : [], + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + }, +}); +// The production atom is read-only; the fake session source is writable. +const source = (id: EnvironmentId) => + environmentSession.sessionStateAtom(id) as unknown as Atom.Writable< + AsyncResult.AsyncResult + >; +const releases: Array<() => void> = []; +beforeEach(() => { + harness.lastAtom = null; + appAtomRegistry.set(source(primary), AsyncResult.success(session(true))); + appAtomRegistry.set(source(secondary), AsyncResult.initial()); +}); +afterEach(() => { + releases.splice(0).forEach((release) => release()); +}); + +it("keeps pending and denied environments out of worker grants while retaining valid cached access", () => { + expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); + expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); + const environments = [{ environmentId: primary }, { environmentId: secondary }]; + expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual( + new Set([primary]), + ); + appAtomRegistry.set(source(secondary), AsyncResult.waiting(AsyncResult.success(session(true)))); + expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(true); + expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual( + new Set([primary, secondary]), + ); + appAtomRegistry.set(source(secondary), AsyncResult.success(session(false))); + expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false); +}); + +it("reacts to grant revocation, failure, and regrant without a connection list change", () => { + const environments = [{ environmentId: primary }, { environmentId: secondary }]; + useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope); + const observed = harness.lastAtom; + if (!observed) throw new Error("Missing worker grant atom"); + const changes: unknown[] = []; + releases.push(appAtomRegistry.subscribe(observed, (value) => changes.push(value))); + appAtomRegistry.set(source(secondary), AsyncResult.success(session(true))); + expect(appAtomRegistry.get(observed)).toEqual(new Set([primary, secondary])); + appAtomRegistry.set(source(secondary), AsyncResult.success(session(false))); + expect(appAtomRegistry.get(observed)).toEqual(new Set([primary])); + appAtomRegistry.set( + source(primary), + AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))), + ); + expect(appAtomRegistry.get(observed)).toEqual(new Set()); + appAtomRegistry.set(source(secondary), AsyncResult.success(session(true))); + expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary])); + expect(changes).not.toHaveLength(0); +}); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 747ab7c72ee2..c267df335fd5 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -1,13 +1,71 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); +const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); + +function sessionHasScope( + result: AsyncResult.AsyncResult, + scope: AuthEnvironmentScope, +): boolean { + const session = Option.getOrNull(AsyncResult.value(result)); + return ( + result._tag !== "Failure" && + session?.authenticated === true && + session.scopes?.includes(scope) === true + ); +} + +/** Uses the selected environment's grant, including cached scopes during a refresh. */ +export function useEnvironmentScope( + environmentId: EnvironmentId | null, + scope: AuthEnvironmentScope, +): boolean { + const result = useAtomValue( + environmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(environmentId), + ); + return sessionHasScope(result, scope); +} + +/** Keeps background workers subscribed to each target's grant. */ +export function useEnvironmentsWithScope( + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, + scope: AuthEnvironmentScope, +): ReadonlySet { + const permittedEnvironments = useMemo( + () => + Atom.make((get) => { + const permitted = new Set(); + for (const { environmentId } of environments) { + if (sessionHasScope(get(environmentSession.sessionStateAtom(environmentId)), scope)) { + permitted.add(environmentId); + } + } + return permitted; + }), + [environments, scope], + ); + return useAtomValue(permittedEnvironments); +} + +export function readEnvironmentScope( + environmentId: EnvironmentId, + scope: AuthEnvironmentScope, +): boolean { + const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); + return sessionHasScope(result, scope); +} + const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( Atom.withLabel("mobile-prepared-connection:empty"), ); diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts new file mode 100644 index 000000000000..ccd2d6c941cb --- /dev/null +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -0,0 +1,226 @@ +import { AuthOrchestrationOperateScope, ApprovalRequestId } from "@t3tools/contracts"; +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + grantedEnvironments: new Set(), + connectionState: "connected", + draft: { text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[] }, + activities: [] as OrchestrationThreadActivity[], + enqueue: vi.fn(async () => undefined), + clearDraft: vi.fn(), + approve: vi.fn(), + answer: vi.fn(), + feedback: vi.fn(), + updateSettings: vi.fn(), + question: { + id: "language", + header: "Language", + question: "Which language?", + options: [{ label: "TypeScript", description: "Use the existing code" }], + multiSelect: false, + }, + thread: { + id: "thread", + environmentId: "secondary", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: { status: "ready", providerName: "codex" }, + latestTurn: null, + }, +})); + +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => {}, + useMemo: (factory: () => A) => factory(), + useState: (initial: A | (() => A)) => [ + typeof initial === "function" ? (initial as () => A)() : initial, + vi.fn(), + ], +})); +vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); +vi.mock("@effect/atom-react", async () => { + const { appAtomRegistry } = await import("./atom-registry"); + return { useAtomValue: (atom: Atom.Atom) => appAtomRegistry.get(atom) }; +}); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), +})); +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: state.thread, + selectedEnvironmentRuntime: { + connectionState: state.connectionState, + serverConfig: { + providers: [{ instanceId: "codex", driver: "codex" }], + environment: { capabilities: {} }, + }, + }, + }), +})); +vi.mock("./use-thread-detail", () => ({ + useSelectedThreadDetail: () => ({ ...state.thread, messages: [], activities: state.activities }), +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); +vi.mock("./threads", () => ({ + threadEnvironment: { + respondToApproval: state.approve, + respondToUserInput: state.answer, + uploadFeedback: state.feedback, + }, +})); +vi.mock("./use-thread-outbox", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) }; +}); +vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue })); +vi.mock("./composer-attachment-uploads", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + composerAttachmentUploadsAtom: Atom.make({}), + composerAttachmentUploadBlockReason: () => null, + }; +}); +vi.mock("./use-composer-drafts", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + composerDraftsAtom: Atom.make({}), + getComposerDraftSnapshot: () => state.draft, + clearComposerDraftContent: state.clearDraft, + setComposerDraftText: (_key: string, text: string) => { + state.draft.text = text; + }, + updateComposerDraftSettings: state.updateSettings, + ensureComposerDraftsLoaded: () => {}, + scheduleUnusedComposerAttachmentCleanup: () => {}, + appendComposerDraftAttachments: vi.fn(), + appendComposerDraftText: vi.fn(), + mergeComposerDraftContent: vi.fn(), + removeComposerDraftAttachment: vi.fn(), + useComposerDraft: () => state.draft, + }; +}); +vi.mock("../lib/composerImages", () => ({ + convertPastedImagesToAttachments: vi.fn(), + pasteComposerClipboard: vi.fn(), + pickComposerFiles: vi.fn(), + pickComposerMedia: vi.fn(), +})); +vi.mock("../lib/commandMetadata", () => ({ + makeQueuedMessageMetadata: () => ({ + messageId: "message", + commandId: "command", + createdAt: "2026-09-05T00:00:00.000Z", + }), +})); +vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); +vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); +vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() })); + +import { useThreadComposerState } from "./use-thread-composer-state"; +import { useSelectedThreadRequests } from "./use-selected-thread-requests"; + +beforeEach(() => { + state.grantedEnvironments = new Set(["primary"]); + state.connectionState = "connected"; + state.draft = { text: "Keep my draft", attachments: [] }; + state.activities = []; + vi.clearAllMocks(); + state.approve.mockResolvedValue(AsyncResult.success(undefined)); + state.answer.mockResolvedValue(AsyncResult.success(undefined)); + state.feedback.mockResolvedValue(AsyncResult.success({ feedbackId: "feedback" })); +}); + +describe("mobile task permissions", () => { + it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => { + const composer = useThreadComposerState(); + expect(await composer.onSendMessage()).toBeNull(); + expect(state.enqueue).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + composer.onChangeDraftMessage("Edited locally"); + composer.onUpdateInteractionMode("plan"); + expect(state.draft.text).toBe("Edited locally"); + expect(state.updateSettings).toHaveBeenCalledWith("secondary:thread", { + interactionMode: "plan", + }); + }); + + it("rechecks a retained send callback before clearing or queueing the draft", async () => { + state.grantedEnvironments.add("secondary"); + const composer = useThreadComposerState(); + state.grantedEnvironments.delete("secondary"); + expect(await composer.onSendMessage()).toBeNull(); + expect(state.clearDraft).not.toHaveBeenCalled(); + expect(state.enqueue).not.toHaveBeenCalled(); + state.grantedEnvironments.add("secondary"); + expect(await composer.onSendMessage()).toBe("message"); + expect(state.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: "secondary", + threadId: "thread", + text: "Keep my draft", + }), + ); + }); + + it("preserves offline queueing without dispatching feedback through the queue action", async () => { + state.connectionState = "disconnected"; + expect(await useThreadComposerState().onSendMessage()).toBe("message"); + expect(state.enqueue).toHaveBeenCalledTimes(1); + state.draft.text = "/feedback The agent stopped early."; + expect(await useThreadComposerState().onSendMessage()).toBeNull(); + expect(state.feedback).not.toHaveBeenCalled(); + expect(state.enqueue).toHaveBeenCalledTimes(1); + expect(state.clearDraft).toHaveBeenCalledTimes(1); + }); + + it("rechecks the target environment before an approval response", async () => { + state.grantedEnvironments.add("secondary"); + const requests = useSelectedThreadRequests(); + state.grantedEnvironments.delete("secondary"); + await requests.onRespondToApproval(ApprovalRequestId.make("approval"), "accept"); + expect(state.approve).not.toHaveBeenCalled(); + state.grantedEnvironments.add("secondary"); + await requests.onRespondToApproval(ApprovalRequestId.make("approval"), "accept"); + expect(state.approve).toHaveBeenCalledWith({ + environmentId: "secondary", + input: { threadId: "thread", requestId: "approval", decision: "accept" }, + }); + }); + + it("keeps answer drafts editable after revocation while blocking their submission", async () => { + state.activities = [ + { + id: "input-activity", + kind: "user-input.requested", + summary: "Choose language", + tone: "info", + turnId: null, + createdAt: "2026-09-05T00:00:00.000Z", + payload: { requestId: "input", questions: [state.question] }, + }, + ] as OrchestrationThreadActivity[]; + const requestId = ApprovalRequestId.make("input"); + useSelectedThreadRequests().onSelectUserInputOption(requestId, state.question, "TypeScript"); + state.grantedEnvironments.add("secondary"); + const requests = useSelectedThreadRequests(); + expect(requests.activePendingUserInputAnswers).toEqual({ language: "TypeScript" }); + state.grantedEnvironments.delete("secondary"); + await requests.onSubmitUserInput(); + expect(state.answer).not.toHaveBeenCalled(); + expect(useSelectedThreadRequests().activePendingUserInputAnswers).toEqual({ + language: "TypeScript", + }); + state.grantedEnvironments.add("secondary"); + await requests.onSubmitUserInput(); + expect(state.answer).toHaveBeenCalledWith({ + environmentId: "secondary", + input: { threadId: "thread", requestId: "input", answers: { language: "TypeScript" } }, + }); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 6208a806819d..03cbd2f1c566 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { ApprovalRequestId, + AuthOrchestrationOperateScope, type ProviderApprovalDecision, type UserInputQuestion, } from "@t3tools/contracts"; @@ -23,6 +24,7 @@ import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadDetail } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; +import { readEnvironmentScope } from "./session"; const userInputDraftsByRequestKeyAtom = Atom.make< Record> @@ -137,7 +139,10 @@ export function useSelectedThreadRequests() { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - if (!selectedThreadShell) { + if ( + !selectedThreadShell || + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) + ) { return; } @@ -157,7 +162,12 @@ export function useSelectedThreadRequests() { ); const onSubmitUserInput = useCallback(async () => { - if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { + if ( + !selectedThreadShell || + !activePendingUserInput || + !activePendingUserInputAnswers || + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) + ) { return; } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 64a7ddeb7882..384e6511cf6e 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -4,6 +4,7 @@ import { Alert } from "react-native"; import * as Cause from "effect/Cause"; import { + AuthOrchestrationOperateScope, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, @@ -59,6 +60,7 @@ import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; +import { readEnvironmentScope } from "./session"; import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, @@ -234,7 +236,11 @@ export function useThreadComposerState() { }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); const onSendMessage = useCallback(async () => { - if (!selectedThreadShell) { + if ( + !selectedThreadShell || + (selectedEnvironmentRuntime?.connectionState === "connected" && + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)) + ) { return null; } @@ -289,6 +295,9 @@ export function useThreadComposerState() { ? parseCodexFeedbackCommand(text) : null; if (feedbackCommand) { + if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)) { + return null; + } if (thread.session === null) { Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); return null; diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts new file mode 100644 index 000000000000..87d906a5f5c9 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -0,0 +1,319 @@ +import { + AuthOrchestrationOperateScope, + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +const state = vi.hoisted(() => ({ + grantedEnvironments: new Set(), + draftText: "Keep this draft separate", + cleanups: [] as Array<() => void>, + start: vi.fn(), + metadata: vi.fn(), + runtime: vi.fn(), + interaction: vi.fn(), + prepare: vi.fn(), + manager: null as unknown as ReturnType< + typeof import("./thread-outbox-manager").createThreadOutboxManager + >, + thread: { + environmentId: "secondary", + id: "thread", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + }, + config: { + providers: [{ instanceId: "codex", driver: "codex" }], + environment: { capabilities: {} }, + }, +})); +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: (effect: () => void | (() => void)) => { + const cleanup = effect(); + if (cleanup) state.cleanups.push(cleanup); + }, + useRef: (value: A) => ({ current: value }), + useState: (initial: A) => [initial, vi.fn()], +})); +vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); +vi.mock("@effect/atom-react", async () => { + const { appAtomRegistry } = await import("./atom-registry"); + return { useAtomValue: (atom: Atom.Atom) => appAtomRegistry.get(atom) }; +}); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), + useEnvironmentsWithScope: () => state.grantedEnvironments, +})); +vi.mock("./entities", () => ({ + useProjects: () => [], + useThreadShells: () => [state.thread], + useServerConfigs: () => new Map([["secondary", state.config]]), +})); +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + serverEnvironment: { configValueAtom: Atom.family((_id: string) => Atom.make(state.config)) }, + }; +}); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + threadEnvironment: { + startTurn: state.start, + updateMetadata: state.metadata, + setRuntimeMode: state.runtime, + setInteractionMode: state.interaction, + }, + environmentThreadShells: { threadShellsAtom: Atom.make([state.thread]) }, + }; +}); +vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); +vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" })); +vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare })); +vi.mock("./use-remote-environment-registry", () => ({ + setPendingConnectionError: vi.fn(), + useRemoteConnectionStatus: () => ({ + connectedEnvironments: [{ environmentId: "secondary", connectionState: "connected" }], + }), +})); +vi.mock("./thread-outbox", async () => { + const { createThreadOutboxManager } = await import("./thread-outbox-manager"); + const { appAtomRegistry } = await import("./atom-registry"); + state.manager = createThreadOutboxManager({ + registry: appAtomRegistry, + storage: { + load: async () => ({ messages: [], errors: [] }), + write: async () => {}, + remove: async () => {}, + }, + }); + return { + threadOutboxManager: state.manager, + threadOutboxRevision: (id: MessageId) => state.manager.revisionOf(id), + confirmThreadOutboxMessageQueued: (message: QueuedThreadMessage) => + state.manager.confirmQueued(message), + updateThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) => + state.manager.update(message, revision), + }; +}); +vi.mock("./thread-outbox-removal", () => ({ + removeThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) => + state.manager.remove(message, revision), +})); +vi.mock("./use-thread-outbox", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + const { appAtomRegistry } = await import("./atom-registry"); + await import("./thread-outbox"); + return { + editingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive), + dispatchingQueuedMessageIdAtom: Atom.make(null).pipe(Atom.keepAlive), + useThreadOutboxMessages: () => appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom), + useThreadOutboxShellStatuses: () => new Map([["secondary", "live"]]), + }; +}); +vi.mock("./use-composer-drafts", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + composerDraftsAtom: Atom.make({}), + removeDeliveredCloudQueuedMessage: async () => {}, + appendComposerDraftAttachments: vi.fn(), + flushComposerDrafts: vi.fn(), + getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), + mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { + state.draftText += `\n${input.text}`; + }), + replaceComposerDraftAttachments: vi.fn(), + undoComposerDraftMerge: vi.fn(), + updateComposerDraftSettings: vi.fn(), + waitForComposerDraftsLoaded: vi.fn(), + }; +}); + +import { appAtomRegistry } from "./atom-registry"; +import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox"; +import { useThreadOutboxDrain } from "./use-thread-outbox-drain"; + +const message = (overrides: Partial = {}): QueuedThreadMessage => ({ + environmentId: EnvironmentId.make("secondary"), + threadId: ThreadId.make("thread"), + commandId: CommandId.make("command"), + messageId: MessageId.make("message"), + text: "Keep this queued", + attachments: [], + createdAt: "2026-09-05T00:00:00.000Z", + ...overrides, +}); +const remaining = () => + Object.values(appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom)).flat(); + +function runDrain() { + const settled = Promise.withResolvers(); + const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => { + if (id === null) settled.resolve(); + }); + useThreadOutboxDrain(); + return settled.promise.finally(release); +} + +beforeEach(() => { + state.grantedEnvironments = new Set(["primary"]); + state.draftText = "Keep this draft separate"; + appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); + for (const command of [state.start, state.metadata, state.runtime, state.interaction]) { + command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + } + state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ + status: "ready", + attachments: [], + draftAttachments: attachments, + pendingAttachmentIds: [], + })); +}); +afterEach(() => { + state.cleanups.splice(0).forEach((cleanup) => cleanup()); +}); + +describe("queued task operation access", () => { + it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => { + const queued = message(); + await state.manager.enqueue(queued); + useThreadOutboxDrain(); + expect(state.start).not.toHaveBeenCalled(); + expect(state.prepare).not.toHaveBeenCalled(); + expect(remaining()).toEqual([queued]); + state.grantedEnvironments.add("secondary"); + await runDrain(); + expect(state.start).toHaveBeenCalledOnce(); + expect(remaining()).toEqual([]); + }); + + it("stops the remaining settings and turn commands when an earlier update loses access", async () => { + state.grantedEnvironments.add("secondary"); + const queued = message({ + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "different-model" }, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + await state.manager.enqueue(queued); + state.metadata.mockImplementationOnce(async () => { + state.grantedEnvironments.delete("secondary"); + return AsyncResult.success(undefined); + }); + await runDrain(); + expect(state.metadata).toHaveBeenCalledOnce(); + expect(state.runtime).not.toHaveBeenCalled(); + expect(state.interaction).not.toHaveBeenCalled(); + expect(state.prepare).not.toHaveBeenCalled(); + expect(state.start).not.toHaveBeenCalled(); + expect(remaining()).toEqual([queued]); + }); + + it.each([ + { isCreation: false, accepted: false }, + { isCreation: true, accepted: false }, + { isCreation: false, accepted: true }, + { isCreation: true, accepted: true }, + ])( + "preserves a rejected queue but removes an accepted turn after access loss (new task: $isCreation, accepted: $accepted)", + async ({ isCreation, accepted }) => { + state.grantedEnvironments.add("secondary"); + const queued = message( + isCreation + ? { + threadId: ThreadId.make("new-thread"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + creation: { + projectId: ProjectId.make("project"), + projectCwd: "/repo", + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + } + : {}, + ); + await state.manager.enqueue(queued); + const started = Promise.withResolvers(); + const delivery = Promise.withResolvers>(); + state.start.mockImplementationOnce(() => { + started.resolve(); + return delivery.promise; + }); + const drained = runDrain(); + await started.promise; + state.grantedEnvironments.delete("secondary"); + delivery.resolve( + accepted + ? AsyncResult.success(undefined) + : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), + ); + await drained; + + expect(remaining()).toEqual(accepted ? [] : [queued]); + expect(state.draftText).toBe("Keep this draft separate"); + if (!accepted) { + state.grantedEnvironments.add("secondary"); + await runDrain(); + expect(state.start).toHaveBeenCalledTimes(2); + expect(remaining()).toEqual([]); + expect(state.draftText).toBe("Keep this draft separate"); + } + }, + ); + + it.each([ + { isCreation: false, uploadFails: false }, + { isCreation: true, uploadFails: false }, + { isCreation: false, uploadFails: true }, + { isCreation: true, uploadFails: true }, + ])( + "keeps the message queued after upload revocation (new task: $isCreation, upload fails: $uploadFails)", + async ({ isCreation, uploadFails }) => { + state.grantedEnvironments.add("secondary"); + const queued = message( + isCreation + ? { + threadId: ThreadId.make("new-thread"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + creation: { + projectId: ProjectId.make("project"), + projectCwd: "/repo", + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + } + : {}, + ); + await state.manager.enqueue(queued); + state.prepare.mockImplementationOnce(async ({ attachments }) => { + state.grantedEnvironments.delete("secondary"); + if (uploadFails) throw new Error("This connection cannot upload attachments."); + return { + status: "ready", + attachments: [], + draftAttachments: attachments, + pendingAttachmentIds: [], + }; + }); + await runDrain(); + expect(state.prepare).toHaveBeenCalledOnce(); + expect(state.start).not.toHaveBeenCalled(); + expect(remaining()).toEqual([queued]); + }, + ); +}); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 9d8f6f02793f..9b767badd3a0 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; const harness = vi.hoisted(() => ({ + canOperate: true, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -95,6 +96,11 @@ vi.mock("./use-atom-command", () => ({ useAtomCommand: () => async () => undefined, })); +vi.mock("./session", () => ({ + readEnvironmentScope: () => harness.canOperate, + useEnvironmentsWithScope: () => new Set(), +})); + vi.mock("./use-thread-outbox", async () => { const { Atom } = await import("effect/unstable/reactivity"); return { @@ -193,6 +199,7 @@ function remainingMessages(): ReadonlyArray { } beforeEach(() => { + harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -209,6 +216,27 @@ afterEach(() => { }); describe("thread outbox attachment preparation", () => { + it("keeps a denied message queued without starting its attachment upload", async () => { + const message = queuedMessage({ + messageId: "denied-queued-message", + text: "Keep this queued", + fileUri: "file:///documents/keep.pdf", + }); + await harness.manager.enqueue(message); + harness.prepareTurnAttachments.mockResolvedValueOnce({ + status: "ready", + attachments: [], + draftAttachments: message.attachments, + pendingAttachmentIds: [], + }); + harness.canOperate = false; + await expect(prepareQueuedMessageAttachments(message)).resolves.toEqual({ + status: "abandoned", + }); + expect(harness.prepareTurnAttachments).not.toHaveBeenCalled(); + expect(remainingMessages()).toEqual([message]); + }); + it("abandons reused uploads when an editor saves changed text during verification", async () => { const message = withReusedFileUpload( queuedMessage({ diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 487aa4da4c2f..e1f18920f81d 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/client-runtime/state/shell"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { + AuthOrchestrationOperateScope, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -25,6 +26,7 @@ import { isModelSelectionUnavailable } from "../lib/modelOptions"; import { appAtomRegistry } from "./atom-registry"; import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { serverEnvironment } from "./server"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; import { confirmThreadOutboxMessageQueued, threadOutboxManager, @@ -128,6 +130,9 @@ export async function prepareQueuedMessageAttachments( if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) { return { status: "abandoned" }; } + if (!readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope)) { + return { status: "abandoned" }; + } const revision = threadOutboxRevision(queuedMessage.messageId); if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) { return { status: "abandoned" }; @@ -513,6 +518,10 @@ export function useThreadOutboxDrain(): void { const projects = useProjects(); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); + const operableEnvironments = useEnvironmentsWithScope( + connectedEnvironments, + AuthOrchestrationOperateScope, + ); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); @@ -641,6 +650,9 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const hasAccess = () => + readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); + if (!hasAccess()) return true; const serverConfig = appAtomRegistry.get( serverEnvironment.configValueAtom(queuedMessage.environmentId), ); @@ -670,6 +682,7 @@ export function useThreadOutboxDrain(): void { } if (settings.runtimeMode !== thread.runtimeMode) { + if (!hasAccess()) return true; const runtimeResult = await setThreadRuntimeMode({ environmentId: queuedMessage.environmentId, input: { @@ -686,6 +699,7 @@ export function useThreadOutboxDrain(): void { } if (settings.interactionMode !== thread.interactionMode) { + if (!hasAccess()) return true; const interactionResult = await setThreadInteractionMode({ environmentId: queuedMessage.environmentId, input: { @@ -704,6 +718,7 @@ export function useThreadOutboxDrain(): void { let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; + if (!hasAccess()) return true; try { const preparedResult = await prepareQueuedMessageAttachments( queuedMessage, @@ -723,6 +738,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { + if (!hasAccess()) return true; console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( @@ -750,6 +766,7 @@ export function useThreadOutboxDrain(): void { settings, currentConfig.providers, ); + if (!hasAccess()) return true; const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -767,6 +784,7 @@ export function useThreadOutboxDrain(): void { createdAt: queuedMessage.createdAt, }, }); + if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { return false; @@ -798,6 +816,9 @@ export function useThreadOutboxDrain(): void { creation: QueuedThreadCreation, projectCwd: string, ) => { + const hasAccess = () => + readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope); + if (!hasAccess()) return true; const modelSelection = queuedMessage.modelSelection; if (modelSelection === undefined) { return false; @@ -843,6 +864,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { + if (!hasAccess()) return true; console.warn("[thread-outbox] failed to upload attachments", error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( @@ -870,6 +892,7 @@ export function useThreadOutboxDrain(): void { settings, currentConfig.providers, ); + if (!hasAccess()) return true; const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -892,6 +915,7 @@ export function useThreadOutboxDrain(): void { }), }); const { reportFailure } = makeDeliveryHelpers(queuedMessage); + if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { return false; @@ -999,6 +1023,9 @@ export function useThreadOutboxDrain(): void { environmentConnected: environment?.connectionState === "connected", threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting", }); + if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) { + continue; + } // The delivery action resolves first; capability checks apply only to // a message that will send. Checking earlier would restore a // creation whose startTurn already made the thread as a duplicate draft @@ -1157,6 +1184,7 @@ export function useThreadOutboxDrain(): void { connectedEnvironments, dispatchingQueuedMessageId, editingQueuedMessageIds, + operableEnvironments, projects, queuedMessagesByThreadKey, retryTick, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 28bb924ccca5..d79184af3ae9 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -112,13 +112,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -318,16 +312,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index d8a0e3048c51..e0ad99abcc34 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,3 +1,4 @@ +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual(AuthAdministrativeScopes); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 982ff397db40..7d2ff33b2249 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -36,6 +36,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.serverUpdateScope).toBe("environment:maintain"); // Packaged desktop has no devUrl, but still needs the port scope: it // scans upward from 3773 for a free port and binds 127.0.0.1, so a second // instance shares this one's hostname on a different port. @@ -90,6 +91,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.serverUpdateScope).toBe("environment:maintain"); expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); }).pipe( Effect.provide( diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 446b8a8bba95..5e59aa6cf362 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -1,4 +1,4 @@ -import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { AuthEnvironmentMaintainScope, type ServerAuthDescriptor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -39,6 +39,7 @@ export const make = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], + serverUpdateScope: AuthEnvironmentMaintainScope, sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index d1d21fcdaa67..695596e16469 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,3 +1,4 @@ +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -76,13 +77,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual(AuthStandardClientScopes); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -161,16 +156,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual(AuthAdministrativeScopes); expect(first.subject).toBe("desktop-bootstrap"); expect(second.method).toBe("desktop-bootstrap"); expect(third.method).toBe("desktop-bootstrap"); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..cd1f560f73ab 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,4 +1,5 @@ import { + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -20,7 +21,7 @@ describe("RPC authorization scopes", () => { AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( - AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de6661f45886..d2e3b6b5d68c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,5 +1,8 @@ import { AuthAccessReadScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -31,36 +34,36 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, - [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, - [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope, - [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, + [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, + [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, + [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, + [WS_METHODS.providerAuthComplete]: AuthProvidersManageScope, + [WS_METHODS.providerAuthCancel]: AuthProvidersManageScope, + [WS_METHODS.providerAuthLogout]: AuthProvidersManageScope, + [WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope, + [WS_METHODS.providerInstallStart]: AuthProvidersManageScope, + [WS_METHODS.providerInstallCancel]: AuthProvidersManageScope, [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope, - [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, - [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, - [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallRemove]: AuthProvidersManageScope, + [WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, + [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, - [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, - [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, + [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, - [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, + [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 97724420f8a9..d1a6f66197da 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId } from "@t3tools/contracts"; +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -183,13 +183,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 919de8b4cc94..6123be6ccf25 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -2,6 +2,9 @@ import { AuthAccessReadScope, AuthAccessWriteScope, AuthStandardClientScopes, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -315,6 +318,9 @@ export const authHttpApiLayer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 57e6cc98418f..f3596c44a8dd 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -8,6 +8,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + AuthAdministrativeScopes, AuthStandardClientScopes, CommandId, EnvironmentOrchestrationHttpApi, @@ -641,28 +642,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, AuthAdministrativeScopes); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, AuthAdministrativeScopes); assert.equal("token" in (listed[0] ?? {}), false); }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1716cd8d4c94..85153956f090 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -49,7 +50,7 @@ import { } from "@t3tools/shared/dpop"; import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; @@ -492,6 +493,7 @@ const buildAppUnderTest = (options?: { onPairingChangesSubscribed?: Effect.Effect; config?: Partial; layers?: { + processDiagnostics?: Partial; keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; @@ -827,6 +829,7 @@ const buildAppUnderTest = (options?: { signaled: true, message: Option.none(), }), + ...options?.layers?.processDiagnostics, }), ), Layer.provide( @@ -1299,9 +1302,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? AuthAdministrativeScopes.join(" "), ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -2371,10 +2372,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, AuthAdministrativeScopes.join(" ")); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -2392,16 +2390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, AuthAdministrativeScopes); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5784,6 +5773,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { instanceId: providerSetupInstanceId, }).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); assert.deepEqual(observed, providerSetupInstallState); + const refreshed = yield* client[WS_METHODS.serverRefreshProviders]({}); + assert.deepEqual(refreshed.providers, []); const errors = [ yield* client[WS_METHODS.providerInstallStart]({ instanceId: providerSetupInstanceId, @@ -5798,7 +5789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { for (const error of errors) { assert.equal(error._tag, "EnvironmentAuthorizationError"); if (error._tag === "EnvironmentAuthorizationError") { - assert.equal(error.requiredScope, "orchestration:operate"); + assert.equal(error.requiredScope, "providers:manage"); } } }), @@ -5809,6 +5800,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("grants settings, provider management and maintenance independently", () => + Effect.gen(function* () { + let settingsWrites = 0; + let signIns = 0; + let processSignals = 0; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + updateSettings: () => + Effect.sync(() => { + settingsWrites += 1; + return DEFAULT_SERVER_SETTINGS; + }), + }, + providerAuth: { + start: () => + Effect.sync(() => { + signIns += 1; + return providerSetupAuthState; + }), + }, + processDiagnostics: { + signal: (input) => + Effect.sync(() => { + processSignals += 1; + return { ...input, signaled: true, message: Option.none() }; + }), + }, + }, + }); + for (const scope of [ + "orchestration:operate", + "settings:write", + "providers:manage", + "environment:maintain", + "settings:write providers:manage", + ]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const check = (request: Effect.Effect, required: ReadonlyArray) => + Effect.gen(function* () { + const missing = required.find((item) => !scope.split(" ").includes(item)); + if (missing === undefined) { + yield* request; + } else { + const error = yield* request.pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: missing, + }); + } + }); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { environmentIcon: null }, + }), + ["settings:write"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { providerInstances: {} }, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { providers: { codex: { enabled: false } }, usageLimitSources: {} }, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { environmentIcon: null, providerInstances: {} }, + }), + ["settings:write", "providers:manage"], + ); + yield* check( + client[WS_METHODS.providerAuthStart]({ + instanceId: providerSetupInstanceId, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverSignalProcess]({ + pid: 123, + startTimeMs: 1, + signal: "SIGINT", + }), + ["environment:maintain"], + ); + }), + ), + ); + } + assert.equal(settingsWrites, 7); + assert.equal(signIns, 2); + assert.equal(processSignals, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup binds private sign-in to the authenticated websocket session", () => Effect.gen(function* () { const flowId = "private-sign-in-flow"; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..710511f737b0 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -422,6 +422,89 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + for (const fallbackId of ["codex", "codex_work"]) { + it.effect(`falls back to enabled instance ${fallbackId} after disabling the selection`, () => + Effect.scoped( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const writerId = ProviderInstanceId.make("writer"); + const fallbackInstanceId = ProviderInstanceId.make(fallbackId); + const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; + const providerInstances = { + [fallbackInstanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + [writerId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: {}, + }, + }; + + yield* serverSettings.updateSettings({ + providers: Object.fromEntries( + Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( + (provider) => [provider, { enabled: false }] as const, + ), + ), + providerInstances, + textGenerationModelSelection: selection, + }); + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + ...providerInstances, + [writerId]: { ...providerInstances[writerId]!, enabled: false }, + }, + }); + const fallbackSelection = { + instanceId: fallbackInstanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }; + assert.deepEqual(next.textGenerationModelSelection, fallbackSelection); + assert.deepEqual( + (yield* serverSettings.getSettings).textGenerationModelSelection, + fallbackSelection, + ); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + assert.deepEqual(change?.textGenerationModelSelection, fallbackSelection); + + const persisted = yield* fileSystem + .readFileString(serverConfig.settingsPath) + .pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))), + ); + assert.deepEqual(persisted.textGenerationModelSelection, selection); + + const restored = yield* serverSettings.updateSettings({ providerInstances }); + assert.deepEqual(restored.textGenerationModelSelection, selection); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + } + + it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + }, + }); + + assert.equal(next.textGenerationModelSelection.instanceId, "claudeAgent"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5f2550534883..44d922d4bc10 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -21,6 +21,7 @@ import { type UsageLimitSourceConfig, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -321,16 +322,23 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + const providerInstances: Record = {}; + for (const [driver, config] of Object.entries(settings.providers)) { + providerInstances[driver] = { driver: ProviderDriverKind.make(driver), config }; + } + Object.assign(providerInstances, settings.providerInstances); + const fallbackEntry = Object.entries(providerInstances).find(([, instance]) => + resolveProviderInstanceEnabled(instance), + ); + if (!fallbackEntry) { return settings; } + const [instanceId, { driver: fallback }] = fallbackEntry; return { ...settings, textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), + instanceId: ProviderInstanceId.make(instanceId), model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback] ?? diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..d9ca360dc004 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -58,6 +58,7 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, RpcClientId, + requiredScopesForServerSettingsPatch, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -622,12 +623,13 @@ const makeWsRpcLayer = ( method: string, effect: Effect.Effect, traceAttributes?: Readonly>, - ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); + requiredScopes: ReadonlyArray = [requiredScopeForRpcMethod(method)], + ) => { + const missingScope = requiredScopes.find((scope) => !currentSession.scopes.includes(scope)); + const authorized: Effect.Effect = + missingScope === undefined ? effect : Effect.fail(authorizationError(missingScope)); + return instrumentRpcEffect(method, authorized, traceAttributes); + }; const observeRpcStream = ( method: string, stream: Stream.Stream, @@ -1974,6 +1976,7 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "server", }, + requiredScopesForServerSettingsPatch(patch), ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..d08f0ec21a14 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; +import { + AuthRelayReadScope, + EnvironmentId, + type EnvironmentCloudLinkStateResult, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -12,6 +16,7 @@ import { useCallback, useMemo } from "react"; import { usePrimaryEnvironment } from "../state/environments"; import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( @@ -43,13 +48,24 @@ function targetKey(target: CloudLinkTarget): string { } export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { - if (target) { + if ( + target && + readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope) + ) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } } +export function readCachedPrimaryCloudLinkState(target: CloudLinkTarget) { + if (!readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope)) + return null; + const result = appAtomRegistry.get(primaryCloudLinkStateAtom(targetKey(target))); + return result._tag === "Success" ? result.value : null; +} + export function usePrimaryCloudLinkState() { const primary = usePrimaryEnvironment(); + const canReadRelay = useEnvironmentScope(primary?.environmentId ?? null, AuthRelayReadScope); const target = useMemo( () => primary?.entry.target._tag === "PrimaryConnectionTarget" @@ -62,9 +78,10 @@ export function usePrimaryCloudLinkState() { : null, [primary], ); - const atom = target - ? primaryCloudLinkStateAtom(targetKey(target)) - : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; + const atom = + target && canReadRelay + ? primaryCloudLinkStateAtom(targetKey(target)) + : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); const refresh = useCallback(() => { refreshPrimaryCloudLinkState(target); @@ -76,7 +93,7 @@ export function usePrimaryCloudLinkState() { } return { - data: Option.getOrNull(AsyncResult.value(result)), + data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), error, isPending: result.waiting, refresh, diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx new file mode 100644 index 000000000000..b06cdfd2c04e --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -0,0 +1,230 @@ +import { + AuthRelayReadScope, + AuthRelayWriteScope, + EnvironmentId, + type EnvironmentCloudLinkStateResult, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + scopes: new Set(), + snapshot: null as EnvironmentCloudLinkStateResult | null, + renderedSnapshot: null as EnvironmentCloudLinkStateResult | null, + getToken: vi.fn<() => Promise>(), + link: vi.fn(), + unlink: vi.fn(), + preferences: vi.fn(), + refreshDiscovery: vi.fn(), + refreshLink: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@clerk/react", () => ({ + useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }), +})); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); +vi.mock("../state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, +})); +vi.mock("../state/session", () => ({ + readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope), +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./linkEnvironmentAtoms", () => ({ + linkPrimaryEnvironment: testState.link, + unlinkPrimaryEnvironment: testState.unlink, + updatePrimaryEnvironmentPreferences: testState.preferences, +})); +vi.mock("./primaryCloudLinkState", () => ({ + readCachedPrimaryCloudLinkState: () => testState.snapshot, + usePrimaryCloudLinkState: () => ({ + target, + data: testState.renderedSnapshot, + error: null, + isPending: false, + refresh: testState.refreshLink, + }), +})); +vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })); + +import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController"; + +const target = { + environmentId: EnvironmentId.make("primary"), + label: "Primary", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773/ws", +}; +const linkedState: EnvironmentCloudLinkStateResult = { + linked: true, + cloudUserId: "account-1", + relayUrl: "https://relay.example.com", + relayIssuer: "https://relay.example.com", + managedTunnelActive: true, + publishAgentActivity: false, +}; + +let renderer: ReactTestRenderer | null = null; +let controller: ReturnType | null = null; + +function ControllerProbe() { + const value = useCloudLinkController(); + useLayoutEffect(() => { + controller = value; + }); + return null; +} + +async function mountController() { + await act(() => { + renderer = create(); + }); +} + +async function reconcile(desired: CloudLinkDesiredState) { + let succeeded = false; + await act(async () => { + if (controller === null) throw new Error("Controller is not mounted."); + succeeded = await controller.reconcileCloudState(desired); + }); + return succeeded; +} + +function expectNoMutations() { + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "error").mockImplementation(() => undefined); + testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); + testState.snapshot = linkedState; + testState.renderedSnapshot = linkedState; + testState.getToken.mockReset().mockResolvedValue("clerk-token"); + for (const command of [ + testState.link, + testState.unlink, + testState.preferences, + testState.refreshDiscovery, + ]) { + command.mockReset().mockResolvedValue(AsyncResult.success({})); + } + testState.refreshLink.mockReset(); + testState.toast.mockReset(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + controller = null; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useCloudLinkController", () => { + it.each([AuthRelayReadScope, AuthRelayWriteScope])( + "does not mutate without %s, even with a cached link snapshot", + async (scope) => { + await mountController(); + testState.scopes.delete(scope); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(testState.getToken).not.toHaveBeenCalled(); + expectNoMutations(); + }, + ); + + it("does not interpret unavailable link state as an unlinked environment", async () => { + testState.snapshot = null; + await mountController(); + + expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(testState.getToken).not.toHaveBeenCalled(); + expectNoMutations(); + }); + + it.each([AuthRelayReadScope, AuthRelayWriteScope])( + "rechecks %s after requesting the cloud token", + async (scope) => { + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.scopes.delete(scope); + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false); + expectNoMutations(); + }, + ); + + it("stops if the link-state read fails while requesting the cloud token", async () => { + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.snapshot = null; + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expectNoMutations(); + }); + + it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { + testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; + testState.renderedSnapshot = testState.snapshot; + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.snapshot = linkedState; + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ + target, + publishAgentActivity: true, + }); + }); + + it("stops the preference write if management permission is lost after linking", async () => { + await mountController(); + testState.link.mockImplementationOnce(async () => { + testState.scopes.delete(AuthRelayWriteScope); + return AsyncResult.success({}); + }); + + expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(testState.link).toHaveBeenCalledOnce(); + expect(testState.preferences).not.toHaveBeenCalled(); + expect(testState.refreshLink).toHaveBeenCalledOnce(); + }); + + it("still lets an authorized user unlink when the cloud token is unavailable", async () => { + await mountController(); + testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable")); + + expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); + }); + + it("unlinks if the link-state read fails while requesting the cloud token", async () => { + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.snapshot = null; + throw new Error("Cloud sign-in unavailable"); + }); + + expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); + expect(testState.refreshLink).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d91596880850..e8fdb6987a25 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -1,5 +1,6 @@ import { useAuth } from "@clerk/react"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts"; import { isAtomCommandInterrupted, settlePromise, @@ -9,13 +10,14 @@ import { useState } from "react"; import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; +import { readEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "./linkEnvironmentAtoms"; -import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; export interface CloudLinkDesiredState { @@ -84,8 +86,30 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Local environment is not ready yet.")); return false; } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const canManageLink = () => { + if ( + !readEnvironmentScope(target.environmentId, AuthRelayReadScope) || + !readEnvironmentScope(target.environmentId, AuthRelayWriteScope) + ) { + reportUpdateFailure( + new Error("This connection needs permission to view and manage T3 Connect settings."), + ); + return false; + } + return true; + }; + const readLinkState = () => { + const state = readCachedPrimaryCloudLinkState(target); + if (state === null) { + reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read.")); + } + return state; + }; + if (!canManageLink()) return false; const wantsLink = desired.managedTunnel || desired.publish; + if (wantsLink && readLinkState() === null) return false; + const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + if (!canManageLink()) return false; // A failure after this point may follow a partially applied mutation (e.g. // the link succeeded but the preference update did not), so every exit — @@ -106,6 +130,8 @@ export function useCloudLinkController() { return false; } } else { + const currentLinkState = readLinkState(); + if (currentLinkState === null) return false; if (tokenResult._tag === "Failure") { reportUpdateFailure(squashAtomCommandFailure(tokenResult)); return false; @@ -115,7 +141,8 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); return false; } - if (!linked || managedTunnelActive !== desired.managedTunnel) { + const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked; + if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { const linkResult = await linkPrimaryEnvironment({ target, clerkToken, @@ -129,6 +156,10 @@ export function useCloudLinkController() { return false; } } + if (!canManageLink() || readLinkState() === null) { + primaryCloudLinkState.refresh(); + return false; + } const prefResult = await updatePrimaryEnvironmentPreferences({ target, publishAgentActivity: desired.publish, diff --git a/apps/web/src/components/ChatMarkdown.permissions.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.test.tsx new file mode 100644 index 000000000000..f8f6d6a4aaae --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.permissions.test.tsx @@ -0,0 +1,286 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + ProjectId, + ThreadId, + type AuthEnvironmentScope, + type EditorId, + type ThreadLinkedPullRequest, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { cloneElement, type ReactElement, type ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + allowed: true, + listeners: new Set<() => void>(), + openEditor: vi.fn(), + updateMetadata: vi.fn(), + search: vi.fn(), + choose: vi.fn(), + openFile: vi.fn(), + openExternal: vi.fn(), + copy: vi.fn(), + toast: vi.fn(), + linkedPullRequest: null as ThreadLinkedPullRequest | null, +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useSettings", () => ({ + getClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select ? select(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS, +})); +vi.mock("./ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render, children }: { render: ReactElement; children?: ReactNode }) => + children === undefined ? render : cloneElement(render, undefined, children), + TooltipPopup: () => null, +})); +vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("./ui/toast", () => ({ + toastManager: { add: state.toast }, + stackedThreadToast: (value: unknown) => value, +})); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "updateMetadata" ? state.updateMetadata : state.openEditor, +})); +vi.mock("../state/session", async () => { + const { useSyncExternalStore } = await import("react"); + const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + id !== null && + (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed); + return { + readEnvironmentScope, + useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) => + useSyncExternalStore( + (listener) => { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + () => readEnvironmentScope(id, scope), + ), + usePreparedConnection: () => ({ _tag: "None" }), + }; +}); +vi.mock("../state/server", () => ({ + serverEnvironment: { configValueAtom: () => "config" }, +})); +vi.mock("../state/threads", () => ({ + threadEnvironment: { updateMetadata: "updateMetadata" }, +})); +vi.mock("../state/entities", () => ({ + readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }), + useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }], +})); +vi.mock("../rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, +})); +vi.mock("../remoteOpen", () => ({ + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +})); +vi.mock("../hooks/useLocalStorage", () => ({ + useLocalStorage: () => ["vscode", vi.fn()], + getLocalStorageItem: () => "vscode", + setLocalStorageItem: vi.fn(), +})); +vi.mock("../hooks/useCopyToClipboard", () => ({ + writeTextToClipboard: state.copy, + useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }), +})); +vi.mock("../localApi", () => ({ + readLocalApi: () => ({ + contextMenu: { show: state.choose }, + shell: { openExternal: state.openExternal }, + }), +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0], + matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => + candidate.url === href, + parseChangeRequestUrl: (href: string) => + href === linkedPullRequest.url + ? { repository: linkedPullRequest.repository, number: linkedPullRequest.number } + : null, + useOpenChangeRequestLink: () => vi.fn(), +})); + +import ChatMarkdown from "./ChatMarkdown"; + +const threadRef = { + environmentId: EnvironmentId.make("selected"), + threadId: ThreadId.make("thread"), +}; +const linkedPullRequest: ThreadLinkedPullRequest = { + projectId: ProjectId.make("project"), + repository: "example/repo", + number: 42, + url: "https://github.com/example/repo/pull/42", +}; +const serverConfig = { + availableEditors: ["vscode", "file-manager"] as readonly EditorId[], + shellRevealInFileManager: true, + shellRevealInFileManagerKind: "xdg-open", + environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } }, +}; +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + state.allowed = true; + state.listeners.clear(); + state.linkedPullRequest = null; + state.openEditor.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.updateMetadata.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + state.search.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); + state.choose.mockReset().mockResolvedValue(null); + state.openFile.mockReset(); + state.openExternal.mockReset().mockResolvedValue(undefined); + state.copy.mockReset().mockResolvedValue(undefined); + state.toast.mockReset(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText: state.copy } }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +async function renderMarkdown(text: string) { + await act(async () => { + renderer = create(); + }); +} + +function menuEvent() { + return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 }; +} + +function anchor() { + return renderer!.root.findByType("a"); +} + +async function openContextMenu() { + await act(async () => anchor().props.onContextMenu(menuEvent())); +} + +function offeredActions() { + return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id); +} + +it("removes host actions after revocation while keeping file preview and copying", async () => { + await renderMarkdown("[Readme](docs/readme.md)"); + await openContextMenu(); + expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); + await act(async () => { + state.allowed = false; + for (const listener of state.listeners) listener(); + }); + state.choose.mockResolvedValue("copy-full"); + await openContextMenu(); + expect(offeredActions()).toEqual(["copy-relative", "copy-full"]); + expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"); + await act(async () => anchor().props.onClick(menuEvent())); + expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined); + expect(state.openEditor).not.toHaveBeenCalled(); +}); + +it("does not launch an editor after revocation during a native context menu", async () => { + let choose: (action: string) => void = () => { + throw new Error("Menu not opened"); + }; + state.choose.mockImplementationOnce( + () => + new Promise((resolve) => { + choose = resolve; + }), + ); + await renderMarkdown("[Readme](docs/readme.md)"); + await openContextMenu(); + expect(offeredActions()).toContain("open"); + await act(async () => { + state.allowed = false; + choose("open"); + }); + expect(state.openEditor).not.toHaveBeenCalled(); +}); + +it("does not reveal a file after revocation during its workspace lookup", async () => { + let finishLookup: () => void = () => { + throw new Error("Lookup not started"); + }; + state.search.mockImplementationOnce( + () => + new Promise((resolve) => { + finishLookup = () => resolve(AsyncResult.success({ entries: [] })); + }), + ); + state.choose.mockResolvedValue("reveal"); + await renderMarkdown("[Readme](readme.md)"); + await openContextMenu(); + expect(state.search).toHaveBeenCalledOnce(); + await act(async () => { + state.allowed = false; + finishLookup(); + }); + expect(state.openEditor).not.toHaveBeenCalled(); +}); + +it.each([false, true])( + "keeps PR links usable without offering metadata edits (linked=%s)", + async (linked) => { + state.allowed = false; + state.linkedPullRequest = linked ? linkedPullRequest : null; + state.choose.mockResolvedValue("open-external"); + await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await openContextMenu(); + expect(offeredActions()).toEqual(["open-external", "copy-link"]); + expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url); + expect(state.updateMetadata).not.toHaveBeenCalled(); + }, +); + +it.each(["link-to-thread", "unlink-from-thread"])( + "rechecks %s after a native context menu returns", + async (action) => { + state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null; + let choose: (action: string) => void = () => { + throw new Error("Menu not opened"); + }; + state.choose.mockImplementationOnce( + () => + new Promise((resolve) => { + choose = resolve; + }), + ); + await renderMarkdown(`[Review](${linkedPullRequest.url})`); + await openContextMenu(); + expect(offeredActions()).toContain(action); + await act(async () => { + state.allowed = false; + choose(action); + }); + expect(state.updateMetadata).not.toHaveBeenCalled(); + + await act(async () => { + state.allowed = true; + for (const listener of state.listeners) listener(); + }); + state.choose.mockResolvedValue(action); + await openContextMenu(); + expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null, + }, + }); + }, +); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index f4551a93088e..30edacd3811f 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -35,10 +35,19 @@ vi.mock("./ui/tooltip", async () => { }); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - usePreparedConnection: () => ({ _tag: "Loading" }), -})); +vi.mock("../state/session", async (importOriginal) => { + const actual = await importOriginal(); + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const grantedScopes = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && grantedScopes.has(scope); + return { + ...actual, + useEnvironmentScope: hasScope, + readEnvironmentScope: hasScope, + usePreparedConnection: () => ({ _tag: "Loading" }), + }; +}); vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 614c404d978e..78fadfb46a9c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -147,9 +148,10 @@ import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; -import { usePreparedConnection } from "../state/session"; +import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; @@ -1989,16 +1991,15 @@ function useChatMarkdownState({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; + const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const remoteOpen = useRemoteOpenResolution(environmentId); - const canUseShellActions = canUseMarkdownFileShellActions( - environmentId, - remoteOpen.state.mode, - remoteOpen.isResolved, - ); + const canUseShellActions = + canOperateHost && + canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved); const preparedConnection = usePreparedConnection(environmentId); const openMarkdownMedia = useCallback( (source: string, resolvedFilePath?: string) => { @@ -2064,6 +2065,13 @@ function useChatMarkdownState({ ), ); } + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) { + return Promise.resolve( + AsyncResult.failure( + Cause.fail(new Error("This connection cannot reveal files on this environment.")), + ), + ); + } return openInEditor({ environmentId, input: { cwd: filePath, editor: "file-manager", reveal: true }, @@ -2356,6 +2364,7 @@ function useChatMarkdownState({ const componentState = useMemo( () => ({ + canOperateHost, cwd, diffThemeName, environmentId, @@ -2382,6 +2391,7 @@ function useChatMarkdownState({ updateThreadPullRequestLink, }), [ + canOperateHost, cwd, diffThemeName, environmentId, @@ -2505,6 +2515,7 @@ const CHAT_MARKDOWN_COMPONENTS = { }, a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { const { + canOperateHost, cwd, environmentId, imageBaseDir, @@ -2642,8 +2653,9 @@ const CHAT_MARKDOWN_COMPONENTS = { const pullRequest = resolveThreadPullRequest(href); const currentPullRequest = threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; - const threadLinkAction = - currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) + const threadLinkAction = !canOperateHost + ? undefined + : currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) ? "unlink-from-thread" : pullRequest === null ? undefined diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 39be0eedafe2..2147dfe618f3 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -20,10 +20,19 @@ vi.mock("../assets/assetUrls", () => ({ vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../state/session", async (importOriginal) => ({ - ...(await importOriginal()), - usePreparedConnection: () => ({ _tag: "Loading" }), -})); +vi.mock("../state/session", async (importOriginal) => { + const actual = await importOriginal(); + const { AuthStandardClientScopes } = await import("@t3tools/contracts"); + const grantedScopes = new Set(AuthStandardClientScopes); + const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && grantedScopes.has(scope); + return { + ...actual, + useEnvironmentScope: hasScope, + readEnvironmentScope: hasScope, + usePreparedConnection: () => ({ _tag: "Loading" }), + }; +}); vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b27d66c7611d..fdd3c5a67cf4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,8 +1,11 @@ import { + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, DEFAULT_MODEL, + EnvironmentAuthorizationError, type EnvironmentId, type MessageId, type ModelSelection, @@ -183,7 +186,8 @@ import { foldSubagentActivities, } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar } from "./BranchToolbar"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -196,7 +200,10 @@ import { } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, @@ -413,8 +420,13 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { readPreparedConnection } from "../state/session"; +import { + readEnvironmentScope, + readPreparedConnection, + useEnvironmentScope, +} from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; import { @@ -1366,6 +1378,7 @@ export default function ChatView(props: ChatViewProps) { reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, } = props; + const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const draftId = routeKind === "draft" ? props.draftId : null; const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; @@ -1376,43 +1389,50 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateProject = useOrchestrationCommand(projectEnvironment.update, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); + const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); - const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false }); + const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false }); + const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); - const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); - const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); - const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); - const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); - const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { + const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); - const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { + const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); - const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { + const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, { reportFailure: false, }); - const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { + const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); @@ -2975,7 +2995,7 @@ export default function ChatView(props: ChatViewProps) { const revertTurnCountByUserMessageId = useMemo(() => { const next = buildRevertTurnCountByUserMessageId( { - supportsConversationRollback, + supportsConversationRollback: supportsConversationRollback && canOperateThread, timelineEntries, turnDiffSummaryByAssistantMessageId, inferredCheckpointTurnCountByTurnId, @@ -2985,6 +3005,7 @@ export default function ChatView(props: ChatViewProps) { lastRevertTurnCountRef.current = next; return next; }, [ + canOperateThread, supportsConversationRollback, inferredCheckpointTurnCountByTurnId, timelineEntries, @@ -3013,6 +3034,9 @@ export default function ChatView(props: ChatViewProps) { resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const scriptKeybindings = + useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? + DEFAULT_RESOLVED_KEYBINDINGS; const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); const manualCompactionProviderAvailable = useMemo( () => @@ -3524,9 +3548,43 @@ export default function ChatView(props: ChatViewProps) { projectCwd: string; previousScripts: ReadonlyArray; nextScripts: ReadonlyArray; - keybinding?: string | null; + keybinding: NewProjectScriptInput["keybinding"]; keybindingCommand: KeybindingCommand; }): Promise> => { + const previousKeybinding = keybindingValueForCommand( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [], + input.keybindingCommand, + ); + const isDeletingScript = !input.nextScripts.some( + (script) => commandForProjectScript(script.id) === input.keybindingCommand, + ); + const changesKeybinding = + isElectron && + input.keybinding !== undefined && + (input.keybinding?.trim() || null) !== previousKeybinding && + (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); + if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSettingsWriteScope, + message: "This connection cannot change keyboard shortcuts.", + }), + ), + ); + } + const keybindingRule = changesKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: input.keybinding, + command: input.keybindingCommand, + }) + : null; + const previousTarget = changesKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: previousKeybinding, + command: input.keybindingCommand, + }) + : null; const updateResult = mapAtomCommandResult( await updateProject({ environmentId, @@ -3541,23 +3599,38 @@ export default function ChatView(props: ChatViewProps) { return updateResult; } - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); - - if (isElectron && keybindingRule) { + if (!changesKeybinding) return updateResult; + if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return isDeletingScript + ? updateResult + : AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSettingsWriteScope, + message: + "The script was saved, but this connection can no longer change keyboard shortcuts.", + }), + ), + ); + } + if (keybindingRule) { return mapAtomCommandResult( await upsertKeybinding({ environmentId, - input: keybindingRule, + input: previousTarget ? { ...keybindingRule, replace: previousTarget } : keybindingRule, }), () => undefined, ); } + if (previousTarget) { + return mapAtomCommandResult( + await removeKeybinding({ environmentId, input: previousTarget }), + () => undefined, + ); + } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, removeKeybinding, updateProject, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3828,7 +3901,6 @@ export default function ChatView(props: ChatViewProps) { } const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; - threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); const openSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, activeThreadRef, @@ -3846,8 +3918,14 @@ export default function ChatView(props: ChatViewProps) { .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); } + if (!canOperateThread) return; + threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; + if (!readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)) { + threadPrRelinkKeysRef.current.delete(activeThreadKey); + return; + } const result = await updateThreadMetadata({ environmentId: activeThreadRef.environmentId, input: { @@ -3870,6 +3948,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThreadKey, activeThreadRef, + canOperateThread, isServerThread, persistedLinkedThreadPullRequest, replacementLinkedThreadPullRequest, @@ -5193,7 +5272,7 @@ export default function ChatView(props: ChatViewProps) { ]); const activeThreadSettled = supportsSettlement && activeThreadShell?.settledOverride === "settled"; - const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, { reportFailure: false, }); // Keyed by thread, not a boolean: the pending state must follow the thread @@ -5202,7 +5281,11 @@ export default function ChatView(props: ChatViewProps) { const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; const handleUnsettleActiveThread = useCallback(async () => { - if (!activeThreadRef) return; + if ( + !activeThreadRef || + !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) + ) + return; const threadKey = scopedThreadKey(activeThreadRef); setUnsettlingThreadKey(threadKey); try { @@ -5224,13 +5307,17 @@ export default function ChatView(props: ChatViewProps) { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); - const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { + const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { reportFailure: false, }); const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; const handleUnsnoozeActiveThread = useCallback(async () => { - if (!activeThreadRef) return; + if ( + !activeThreadRef || + !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) + ) + return; const threadKey = scopedThreadKey(activeThreadRef); setUnsnoozingThreadKey(threadKey); try { @@ -5368,7 +5455,8 @@ export default function ChatView(props: ChatViewProps) { setIsStoppingBackgroundWork(false); }, [activeThreadId]); const handleStopBackgroundWork = useCallback(async () => { - if (!activeThread) return; + if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; setIsStoppingBackgroundWork(true); const result = await interruptThreadTurn({ environmentId, @@ -5413,7 +5501,7 @@ export default function ChatView(props: ChatViewProps) { + + + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 650ad12bebcb..6ee85d4f8def 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -44,7 +44,9 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { + AuthOrchestrationOperateScope, type ContextMenuItem, + type EnvironmentId, ProjectId, type ScopedThreadRef, type ResolvedKeybindingsConfig, @@ -93,7 +95,9 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { previewEnvironment } from "../state/preview"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, @@ -351,6 +355,18 @@ interface SidebarThreadRowProps { ) => boolean; } +function checkTaskPermission(environmentId: EnvironmentId): boolean { + if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Action unavailable", + description: "This connection cannot change threads or projects.", + }), + ); + return false; +} + export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, @@ -378,6 +394,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr openPrLink, thread, } = props; + const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); @@ -489,10 +506,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr visibleGitStatus?.sourceControlProvider, ); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; + const isConfirmingArchive = + canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" - : !isThreadRunning + : canOperateThread && !isThreadRunning ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; const clearConfirmingArchive = useCallback(() => { @@ -521,6 +539,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ); const handleRowDoubleClick = useCallback( (event: React.MouseEvent) => { + if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; // Already renaming this row: a double-click on the row chrome (outside the // input) must not restart and discard the in-progress edit. if (renamingThreadKey === threadKey) return; @@ -535,7 +554,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr event.preventDefault(); startThreadRename(threadKey, thread.title); }, - [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title], ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -680,12 +699,13 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); + if (!checkTaskPermission(thread.environmentId)) return; setConfirmingArchiveThreadKey(threadKey); requestAnimationFrame(() => { confirmArchiveButtonRefs.current.get(threadKey)?.focus(); }); }, - [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId], ); const handleArchiveImmediateClick = useCallback( (event: React.MouseEvent) => { @@ -747,7 +767,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr )} {threadStatus && } - {renamingThreadKey === threadKey ? ( + {canOperateThread && renamingThreadKey === threadKey ? ( Confirm - ) : !isThreadRunning ? ( + ) : canOperateThread && !isThreadRunning ? ( appSettingsConfirmThreadArchive ? (
- + diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..8f55d74e95fa 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,8 +1,11 @@ -import type { - ProjectScript, - ResolvedKeybindingsConfig, - T3ProjectFileScript, +import { + AuthSettingsWriteScope, + type EnvironmentId, + type ProjectScript, + type T3ProjectFileScript, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -12,6 +15,8 @@ import { useCallback, useMemo, useState } from "react"; import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; +import { serverEnvironment } from "~/state/server"; +import { readEnvironmentScope } from "~/state/session"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -40,10 +45,10 @@ export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; interface ProjectScriptsControlProps { + environmentId: EnvironmentId; scripts: ReadonlyArray; /** Scripts declared in the project's checked-in t3.json, offered for import. */ fileScripts?: ReadonlyArray; - keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise; @@ -55,15 +60,18 @@ interface ProjectScriptsControlProps { } export default function ProjectScriptsControl({ + environmentId, scripts, fileScripts = NO_FILE_SCRIPTS, - keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { + const keybindings = + useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? + DEFAULT_RESOLVED_KEYBINDINGS; const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, @@ -113,7 +121,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, - keybinding: null, + ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}), previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, }; @@ -288,6 +296,7 @@ export default function ProjectScriptsControl({ )} ({ groups: [] as LocalEnvironmentUpdateGroup[], + permittedEnvironmentIds: new Set(), updateProvider: vi.fn(), })); @@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({ serverEnvironment: { updateProvider: Symbol("updateProvider") }, })); +vi.mock("~/state/session", () => ({ + readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) => + scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId), + useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && + scope === AuthProvidersManageScope && + testState.permittedEnvironmentIds.has(environmentId), +})); + vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => testState.updateProvider, })); @@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => { beforeEach(() => { vi.useFakeTimers(); hooks.reset(); + testState.permittedEnvironmentIds.clear(); + testState.permittedEnvironmentIds.add(environmentId); testState.updateProvider.mockReset(); const candidate = provider() as ProviderUpdateCandidate; testState.groups = [ diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 56aaa9e96ff0..d2332c2510e2 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -1,6 +1,10 @@ import { CheckIcon } from "lucide-react"; import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; -import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -9,6 +13,7 @@ import { import { cn } from "~/lib/utils"; import { serverEnvironment } from "~/state/server"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; import { @@ -115,6 +120,7 @@ function EnvironmentUpdateRow({ readonly status: ProviderUpdateRowStatus; readonly onUpdate: () => void; }) { + const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope); let trailing: ReactNode; switch (status.kind) { case "loading": @@ -126,14 +132,14 @@ function EnvironmentUpdateRow({ case "failed": case "unchanged": trailing = ( - ); break; default: trailing = ( - ); @@ -145,6 +151,11 @@ function EnvironmentUpdateRow({
{group.label} {status.text} + {!canManageProviders ? ( + + This connection cannot manage provider accounts. + + ) : null}
{trailing}
@@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({ const handleUpdate = useCallback( async (environmentId: EnvironmentId) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; const group = groupByEnvironment.get(environmentId); if (!group || group.candidates.length === 0) { return; diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index a0ed3d80467f..440c1574edc3 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + type ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironment } from "../state/environments"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { @@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set(); type ProviderUpdateToastId = ReturnType; type ActiveProviderUpdateToast = - | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } + | { + readonly kind: "prompt"; + readonly key: string; + readonly toastId: ProviderUpdateToastId; + readonly canManageProviders: boolean; + } | { readonly kind: "update"; readonly key: string; @@ -105,6 +115,10 @@ export function ProviderUpdatePrimaryNotification() { const navigate = useNavigate(); const providers = useAtomValue(primaryServerProvidersAtom); const primaryEnvironment = usePrimaryEnvironment(); + const canManageProviders = useEnvironmentScope( + primaryEnvironment?.environmentId ?? null, + AuthProvidersManageScope, + ); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); @@ -183,11 +197,15 @@ export function ProviderUpdatePrimaryNotification() { activeToastRef.current = null; } + const updateExistingPrompt = + activeToast?.kind === "prompt" && + activeToast.key === notificationKey && + activeToast.canManageProviders !== canManageProviders; if ( !notificationKey || dismissedNotificationKeys.has(notificationKey) || - seenProviderUpdateNotificationKeys.has(notificationKey) || - activeToastRef.current + (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) || + (activeToastRef.current && !updateExistingPrompt) ) { return; } @@ -204,7 +222,12 @@ export function ProviderUpdatePrimaryNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { + if ( + updateStarted || + oneClickProviders.length === 0 || + !primaryEnvironment || + !readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope) + ) { return; } updateStarted = true; @@ -224,6 +247,19 @@ export function ProviderUpdatePrimaryNotification() { void (async () => { const results = []; for (const provider of oneClickProviders) { + if (!readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)) { + if (activeToastRef.current === activeUpdate) { + addProviderUpdateToast({ + view: getProviderUpdateRejectedToastView( + providerCount, + "This connection cannot manage provider accounts.", + ), + openSettings: openProviderSettings, + }); + activeToastRef.current = null; + } + return; + } results.push( await updateProvider({ environmentId: primaryEnvironment.environmentId, @@ -265,44 +301,52 @@ export function ProviderUpdatePrimaryNotification() { })(); }; - toastId = toastManager.add( - stackedThreadToast({ - type: initialView.type, - title: initialView.title, - description: initialView.description, - timeout: 0, - actionProps: - oneClickProviders.length > 0 - ? { - children: "Update", - onClick: runUpdates, - } - : { + const toastOptions = stackedThreadToast({ + type: initialView.type, + title: initialView.title, + description: canManageProviders + ? initialView.description + : "This connection cannot manage provider accounts. View provider settings for update details.", + timeout: 0, + actionProps: + oneClickProviders.length > 0 + ? { + children: "Update", + disabled: !canManageProviders, + onClick: runUpdates, + } + : { + children: "Settings", + onClick: openSettings, + }, + actionVariant: "outline", + data: { + leadingIcon: + updateProviders.length === 1 ? ( + + ) : undefined, + hideCopyButton: true, + onClose: dismissPrompt, + ...(oneClickProviders.length > 0 + ? { + secondaryActionProps: { children: "Settings", onClick: openSettings, }, - actionVariant: "outline", - data: { - leadingIcon: - updateProviders.length === 1 ? ( - - ) : undefined, - hideCopyButton: true, - onClose: dismissPrompt, - ...(oneClickProviders.length > 0 - ? { - secondaryActionProps: { - children: "Settings", - onClick: openSettings, - }, - secondaryActionVariant: "outline" as const, - } - : {}), - }, - }), - ); - activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; + secondaryActionVariant: "outline" as const, + } + : {}), + }, + }); + if (updateExistingPrompt && activeToast?.kind === "prompt") { + toastId = activeToast.toastId; + toastManager.update(toastId, toastOptions); + } else { + toastId = toastManager.add(toastOptions); + } + activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders }; }, [ + canManageProviders, updateProvider, dismissNotificationKey, dismissedNotificationKeys, diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 67236361f677..3e1a9e34e944 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,7 +1,8 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { AuthSessionState, type EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -9,6 +10,8 @@ const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), continueThreadsAfterServerUpdate: false, + session: null as AsyncResult.AsyncResult | null, + sessionAtom: Symbol("session"), })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -20,6 +23,13 @@ vi.mock("~/hooks/useSettings", () => ({ selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), })); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.session })); +vi.mock("~/rpc/atomRegistry", () => ({ + appAtomRegistry: { get: () => testState.session }, +})); +vi.mock("~/state/session", () => ({ + environmentSession: { sessionStateAtom: () => testState.sessionAtom }, +})); vi.mock("~/state/server", () => ({ serverEnvironment: { updateServer: Symbol("updateServer") }, })); @@ -32,6 +42,8 @@ vi.mock("./ui/toast", () => ({ import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); + type ActionElement = ReactElement<{ readonly onClick?: () => void; }>; @@ -50,11 +62,85 @@ async function flushPromises(): Promise { await Promise.resolve(); } +const legacyAuth = { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", +} as const; + +const currentSession = { + authenticated: true, + scopes: ["environment:maintain"], + auth: { + ...legacyAuth, + serverUpdateScope: "environment:maintain", + }, +} as const satisfies AuthSessionState; + describe("ServerUpdateAction", () => { beforeEach(() => { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.continueThreadsAfterServerUpdate = false; + testState.session = AsyncResult.success(currentSession); + }); + + it.each([ + { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true }, + { serverUpdateScope: undefined, scopes: ["orchestration:read"], allowed: false }, + { + serverUpdateScope: "environment:maintain", + scopes: ["orchestration:operate"], + allowed: false, + }, + { + serverUpdateScope: "environment:maintain", + scopes: ["environment:maintain"], + allowed: true, + }, + ] as const)( + "uses the advertised update scope $serverUpdateScope with grant $scopes", + async ({ serverUpdateScope, scopes, allowed }) => { + testState.session = AsyncResult.success( + decodeSessionState({ + ...currentSession, + scopes, + auth: { + ...legacyAuth, + ...(serverUpdateScope === undefined ? {} : { serverUpdateScope }), + }, + }), + ); + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); + + renderAction().props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0); + }, + ); + + it("keeps a known grant usable while the session refreshes", async () => { + testState.session = AsyncResult.waiting(AsyncResult.success(currentSession)); + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }), + ); + + renderAction().props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledOnce(); + }); + + it("does not dispatch an update after maintenance access is removed", async () => { + const action = renderAction(); + testState.session = AsyncResult.success({ ...currentSession, scopes: [] }); + action.props.onClick?.(); + await flushPromises(); + expect(testState.updateServer).not.toHaveBeenCalled(); }); it("reports success only after the shared update flow reconnects", async () => { diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 1845ada716b6..d49b8c0078bb 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,3 +1,7 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts"; +import type { AsyncResult } from "effect/unstable/reactivity"; +import { environmentSession } from "~/state/session"; import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { @@ -10,6 +14,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; @@ -34,6 +39,17 @@ function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } +function canUpdateServer(result: AsyncResult.AsyncResult): boolean { + if (result._tag !== "Success" || !result.value.authenticated) return false; + const session = result.value; + // Only self-update bridges the old authorization protocol. Upgraded servers + // advertise the new scope even when this client's grant predates it. + return ( + session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === + true + ); +} + /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot @@ -99,6 +115,8 @@ export function ServerUpdateAction({ readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); + const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, @@ -125,7 +143,10 @@ export function ServerUpdateAction({ }); const handleUpdate = async () => { - if (pendingUpdateEnvironmentIds.has(environmentId)) { + if ( + !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || + pendingUpdateEnvironmentIds.has(environmentId) + ) { return; } if (isDesktopAppUpdate) { @@ -140,7 +161,10 @@ export function ServerUpdateAction({ return; } } - if (pendingUpdateEnvironmentIds.has(environmentId)) { + if ( + !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || + pendingUpdateEnvironmentIds.has(environmentId) + ) { return; } pendingUpdateEnvironmentIds.add(environmentId); @@ -195,7 +219,7 @@ export function ServerUpdateAction({ } return ( - ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 10898e311cef..de0cdbdbcf32 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -14,6 +14,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + AuthOrchestrationOperateScope, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -822,6 +823,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useAtomCommand } from "../../state/use-atom-command"; +import { readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -1043,6 +1045,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; + canOperateThread: boolean; activeContextWindow: ContextWindowSnapshot | null; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; @@ -1083,6 +1086,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} store.getComposerDraft); useEffect(() => { - if (!attachmentUploadsCapabilityKnown) { + if (!attachmentUploadsCapabilityKnown || !canOperateThread) { return; } if (!supportsAttachmentUploads) { @@ -1521,6 +1527,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, [ attachmentUploadsCapabilityKnown, + canOperateThread, composerDraftTarget, composerFiles, composerImages, @@ -2845,7 +2852,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { - if (noProviderAvailable || isSendDisabled) { + if ( + noProviderAvailable || + isSendDisabled || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { event?.preventDefault(); return; } @@ -2884,6 +2895,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, activePendingProgress, blurMobileComposerAfterSend, + environmentId, isSendDisabled, noProviderAvailable, onSend, @@ -2902,6 +2914,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [isMobileViewport, routeKind, submitComposer]); const compactThreadContext = useCallback(() => { if ( + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || compactDisabled || noProviderAvailable || activePendingApproval !== null || @@ -2941,6 +2954,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, compactDisabled, composerDraftTarget, + environmentId, isConnecting, isSendBusy, noProviderAvailable, @@ -4875,6 +4889,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 0 ? ( - retryAttachmentUpload({ - environmentId, - image, - draftTarget: composerDraftTarget, - }), + ...(canOperateThread + ? { + onRetryUpload: (image: ComposerImageAttachment) => + retryAttachmentUpload({ + environmentId, + image, + draftTarget: composerDraftTarget, + }), + } + : {}), } : {})} onRemove={(annotationId) => { @@ -5249,6 +5271,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) draftTarget: composerDraftTarget, }) } + disabled={!canOperateThread} aria-label={`Retry upload for ${image.name}`} /> } @@ -5328,6 +5351,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) draftTarget: composerDraftTarget, }) } + disabled={!canOperateThread} aria-label={`Retry upload for ${file.name}`} /> } @@ -5401,6 +5425,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) draftTarget: composerDraftTarget, }) } + disabled={!canOperateThread} aria-label={`Retry upload for ${file.name}`} /> } @@ -5509,6 +5534,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) > {activeProjectScripts && ( | undefined; onRespondToApproval: ( requestId: ApprovalRequestId, @@ -29,6 +30,7 @@ const DEFAULT_APPROVAL_OPTIONS = [ export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ requestId, isResponding, + disabled = false, options = DEFAULT_APPROVAL_OPTIONS, onRespondToApproval, }: ComposerPendingApprovalActionsProps) { @@ -49,9 +51,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro ? " text-warning" : "" }`} - disabled={isResponding} + disabled={disabled || isResponding} aria-description={option.warning} - onClick={() => void onRespondToApproval(requestId, option.decision)} + onClick={() => { + if (!disabled && !isResponding) { + void onRespondToApproval(requestId, option.decision); + } + }} > {option.warning ? : null} {option.label} diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx new file mode 100644 index 000000000000..d2e82963abde --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx @@ -0,0 +1,89 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { expect, it, vi } from "vite-plus/test"; + +vi.mock("../ui/collapsible", () => { + const Children = ({ children }: { children: ReactNode }) => <>{children}; + return { + Collapsible: Children, + CollapsiblePanel: Children, + CollapsibleTrigger: Children, + }; +}); + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + const onToggleOption = vi.fn(); + const onAdvance = vi.fn(); + const prompt = { + requestId: ApprovalRequestId.make("scoped-answer"), + createdAt: "2026-09-05T00:00:00.000Z", + questions: [ + { + id: "approach", + header: "Approach", + question: "Which approach?", + options: [{ label: "Incremental", description: "One module at a time" }], + multiSelect: false, + }, + ], + }; + const panel = (disabled: boolean) => ( + + ); + let renderer: ReactTestRenderer | undefined; + const chooseOption = () => { + const option = renderer!.root + .findAllByType("button") + .find((button) => + button.findAllByType("span").some((span) => span.children.includes("Incremental")), + ); + expect(option).toBeDefined(); + option!.props.onClick(); + }; + try { + await act(async () => { + renderer = create(panel(false)); + }); + await act(async () => chooseOption()); + expect(onToggleOption).toHaveBeenCalledWith("approach", "Incremental"); + expect(onAdvance).not.toHaveBeenCalled(); + + await act(async () => renderer!.update(panel(true))); + await act(async () => { + chooseOption(); + vi.runAllTimers(); + }); + expect(onToggleOption).toHaveBeenCalledTimes(1); + expect(onAdvance).not.toHaveBeenCalled(); + + await act(async () => renderer!.update(panel(false))); + await act(async () => { + chooseOption(); + vi.runAllTimers(); + }); + expect(onToggleOption).toHaveBeenCalledTimes(2); + expect(onAdvance).toHaveBeenCalledTimes(1); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a7df2488233..78dae48175f4 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -12,6 +12,7 @@ import { ComposerBanner } from "./ComposerBanner"; interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; + disabled?: boolean; respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; @@ -21,6 +22,7 @@ interface PendingUserInputPanelProps { export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, + disabled = false, respondingRequestIds, answers, questionIndex, @@ -35,6 +37,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn ; questionIndex: number; @@ -80,6 +85,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onAdvanceRef.current = onAdvance; }, [onAdvance]); + useEffect(() => { + if (disabled && autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + }, [disabled]); + useEffect(() => { if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { return; @@ -112,6 +124,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => { + if (disabled || isResponding) return; if (activeQuestion?.multiSelect) { onToggleOption(questionId, optionValue); return; @@ -126,7 +139,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onAdvanceRef.current(); }, 200); }, - [activeQuestion, onToggleOption], + [activeQuestion, disabled, isResponding, onToggleOption], ); // Keyboard shortcut: number keys 1-9 select corresponding options when focus is @@ -134,7 +147,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // select prompts keep the existing auto-advance behavior. Collapsed prompts opt // out, since the numbers they refer to are not on screen. useEffect(() => { - if (!activeQuestion || isResponding || isCollapsed) return; + if (!activeQuestion || disabled || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -158,7 +171,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, handleOptionSelection, isCollapsed, isResponding]); + }, [activeQuestion, disabled, handleOptionSelection, isCollapsed, isResponding]); if (!activeQuestion) { return null; @@ -221,8 +234,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isSelected ? "bg-muted/55 text-foreground" : "bg-transparent text-foreground/85 hover:bg-muted/30", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", + (disabled || isResponding) && "opacity-50 cursor-not-allowed", + !disabled && !isResponding && "cursor-pointer", ); const content = ( <> @@ -249,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( - + {options.length === 0 && No installed editors found} {options.map(({ label, Icon, value, kind }) => ( - openInEditor(value)}> + openInEditor(value)} + >