diff --git a/AGENTS.md b/AGENTS.md index ef6dbcff766a745374ccfd17f477ec778f575024..3d1c49d519023e3288b7f5143543aaf96495a30c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method. - Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md). - `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md). - Inferred types over annotations. `any` is the enemy. diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 74d06207da9131ddfde64421dbe5448fdb91c0d3..dd17b610bfad1f790082470c0244a5d7c4a16b02 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(layerTest)); }); + + 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(layerTest)); + }); + + 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(layerTest)), + ); + + 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(layerTest)); + }); + + 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(layerTest)); + }); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 1b30769e7bcd8cadb2f8cdaba09449def236cd8b..826edaf839b9118a6e9a3890e42f685fbe93e744 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 f0cebef2cee73602e767e020b38e025166f42780..eacd5544ea02db1941a42782c2a90db73a5cba89 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, @@ -30,6 +31,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; 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 type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; import { SettingsScreenContent } from "../settings/components/SettingsScreen"; @@ -191,24 +193,74 @@ function ArchivedThreadRow(props: { }) { const { width: windowWidth } = useWindowDimensions(); const cardColor = useUniwindTheme()["--color-grouped-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 0000000000000000000000000000000000000000..4fffe5f36c10df2de2fbd1729b1e1d723b7e91b4 --- /dev/null +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -0,0 +1,326 @@ +import { makeThreadShellFixture } from "../../test-fixtures"; +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/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + pendingOrder: null as object | null, + dropBusy: false, + 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: { + set: (_atom: string, value: boolean) => { + state.dropBusy = value; + }, + get: (atom: string) => + atom === "thread-drop-busy" + ? state.dropBusy + : atom === "thread-shells" + ? state.shells + : atom === "queued-thread-keys" + ? new Set() + : 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, +})); +// Stubbed at the direct dependency: the real outbox pulls the Expo file-system +// storage into a test that only reads which threads are queued. +vi.mock("../../state/use-thread-outbox", () => ({ queuedThreadKeysAtom: "queued-thread-keys" })); +// The real hold lives in a module-level atom that would leak between cases. +vi.mock("../../state/thread-order", () => ({ + threadDropBusyAtom: "thread-drop-busy", + getPendingThreadOrder: () => state.pendingOrder, + beginPendingThreadOrder: () => { + state.pendingOrder = {}; + return { + isPending: () => state.pendingOrder !== null, + complete: () => { + state.pendingOrder = null; + }, + cancel: () => { + state.pendingOrder = null; + }, + }; + }, +})); +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 makeThreadShellFixture({ + id: ThreadId.make("thread"), + title: "Thread", + environmentId: primaryEnvironmentId, + projectId: ProjectId.make("project"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "model" }, + ...input, + }); +} + +const mutationCases = [ + ["archiveThread", "archive"], + ["settleThread", "settle"], + ["unsettleThread", "unsettle"], + ["snoozeThread", "snooze"], + ["unsnoozeThread", "unsnooze"], + ["pinThread", "pin"], + ["unpinThread", "unpin"], + ["regenerateThreadTitle", "updateMetadata"], +] as const; + +beforeEach(() => { + state.pendingOrder = null; + state.dropBusy = false; + 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().moveThread(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().moveThread(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().moveThread(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 38a24e8b1656e15d85a348579fcff986c43df2f9..964c12a7189f2b1fb17021d42ffa4ce0e342d0ea 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,6 +1,7 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { canSnooze, effectiveSnoozed } 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"; @@ -13,6 +14,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre import { pinOrderKeyBetween } 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 { queuedThreadKeysAtom } from "../../state/use-thread-outbox"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -108,6 +110,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, @@ -121,6 +129,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; @@ -205,6 +214,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") { @@ -281,6 +291,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; @@ -336,6 +347,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; @@ -383,6 +395,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", @@ -423,6 +436,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", @@ -479,6 +493,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 || @@ -572,6 +587,7 @@ export function useThreadListActions(): { }); const moveThread = useCallback( async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => { + if (!checkThreadOperationPermission(thread, "Could not move thread")) return false; if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false; const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); const current = shells.find( @@ -648,6 +664,11 @@ export function useThreadListActions(): { const shellByKey = new Map( shells.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(); appAtomRegistry.set(threadDropBusyAtom, true); const pending = crossSection @@ -694,6 +715,7 @@ export function useThreadListActions(): { if (pending !== null && !pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; + if (!checkThreadOperationPermission(target, "Could not move thread")) return false; const result = await reorder({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index f25d7844f04a49462ea271afbb8c98baf83301ba..1065a860a5e4c1856350204355e7ca1d99b8d07f 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -1,6 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + AuthOrchestrationReadScope, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; @@ -10,8 +15,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { ScreenScrollView } from "../../components/ScreenScrollView"; +import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; -import { environmentSession } from "../../state/session"; +import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; @@ -75,7 +81,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm (provider) => provider.updateState?.status === "running" || provider.updateState?.status === "queued", ) ?? false; - const disabled = !allowed || pending !== null || running || providerBusy; + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); + const canRead = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); + const busy = !connected || pending !== null || running || providerBusy; + const disabled = !allowed || busy; const version = config?.environment.serverVersion; const checkedRelease = release?.fromVersion === version ? release : null; const capabilities = config?.environment.capabilities; @@ -111,6 +120,16 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm text: "Update", onPress: () => void run("server", async () => { + if ( + AsyncResult.isFailure( + appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), + ) || + !canMaintainEnvironment( + appAtomRegistry.get(environmentSession.sessionStateValueAtom(environmentId)), + connected, + ) + ) + return; const result = await updateServer({ environmentId, input: { @@ -131,7 +150,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm } function requestProviderUpdate(provider: ServerProvider) { - if (disabled || !canUpdateEnvironmentProvider(provider)) return; + if ( + busy || + !readEnvironmentScope(environmentId, AuthProvidersManageScope) || + !canUpdateEnvironmentProvider(provider) + ) + return; void run(provider.instanceId, async () => { const result = await updateProvider({ environmentId, @@ -274,11 +298,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm { - if (disabled) return; + if (busy || !canRead) return; void run("refresh", async () => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) + return; const result = await refreshProviders({ environmentId, input: {} }); if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); setNotice("Provider status refreshed."); @@ -338,7 +364,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm requestProviderUpdate(provider)} /> diff --git a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx index 293841e9571f21f0381b123046e395f3a3dce2a0..25c2e03d46f7366a92e3e5fd706e565fb72cc85e 100644 --- a/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx @@ -1,3 +1,5 @@ +import { AuthRelayWriteScope } from "@t3tools/contracts"; +import { readEnvironmentScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAuth } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; @@ -271,6 +273,8 @@ function ConfiguredSettingsNotificationsRouteScreen() { const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ + canConfigureEnvironment: (environmentId) => + readEnvironmentScope(environmentId, AuthRelayWriteScope), enabled: true, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, @@ -302,7 +306,7 @@ function ConfiguredSettingsNotificationsRouteScreen() { Alert.alert( Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled", environmentCount > 0 - ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.` + ? "Agent activity updates are enabled for environments this connection can configure." : "Agent activity updates are enabled. Add an environment to start receiving updates.", ); } else { @@ -370,6 +374,8 @@ function ConfiguredSettingsNotificationsRouteScreen() { const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ + canConfigureEnvironment: (environmentId) => + readEnvironmentScope(environmentId, AuthRelayWriteScope), enabled: false, previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts index 3e0ead2ce52bff0766ea2a73df52ad68726ae887..41400a50c32c4848d15bb14a69e14a40c3156c8a 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("supports agent awareness settings on Android", () => { diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts index 6cdf8a3e1f8634bd9726e7ffcc485e9db4491628..efa618e814a2a840bcd5584453e9a3769d8096a4 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/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index e8593ff407f01c0ff7862e49bf450af09db06001..e9cc8563f2a9a692609f85e277461e609b98d092 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1,3 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { readEnvironmentScope } from "../../state/session"; import type { EnvironmentId, ProjectId, @@ -580,6 +583,9 @@ function TaskForm({ const projects = useProjects().filter((project) => project.environmentId === environmentId); const config = useEnvironmentServerConfig(environmentId); const modelOptions = useMemo(() => buildModelOptions(config, null), [config]); + const canOperate = useAtomValue( + serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), + ); const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, { label: "scheduled task upsert", reportFailure: false, @@ -602,6 +608,7 @@ function TaskForm({ const save = async () => { if ( + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || submissionPending.current || saving || dictationPending || @@ -937,7 +944,7 @@ function TaskForm({ : draft.task } signatureConfigured={draft.schedule.signature !== null} - disabled={saving || environmentUnavailable} + disabled={!canOperate || saving || environmentUnavailable} /> void save()} className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50" > @@ -1118,6 +1127,9 @@ function EnvironmentTasks({ const visibleTasks = tasks.data?.tasks.filter( (task) => projectIds === null || projectIds.includes(task.projectId), ); + const canOperate = useAtomValue( + serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), + ); const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, { label: "scheduled task enabled", reportFailure: false, @@ -1137,6 +1149,7 @@ function EnvironmentTasks({ }; const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; const result = action === "run" ? await runNow({ environmentId, input: { id: task.id } }) @@ -1205,10 +1218,19 @@ function EnvironmentTasks({ { const action = nativeEvent.event; diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 885f269251c8f9c316c4ced75efdb5a6bfa0f20b..ae4f43b5fb376b2895ba939cf88ae388c1cdc90e 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -1,5 +1,7 @@ import { useNavigation } from "@react-navigation/native"; import { SettingsRow } from "./components/SettingsRow"; +import { AuthSettingsWriteScope } from "@t3tools/contracts"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { @@ -141,6 +143,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const navigation = useNavigation(); const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); + const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); const projectSelected = selectedProjectKey !== null; const targets = resolveMobileSettingsTargets( selectedTargets, @@ -151,6 +154,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const [pendingTargets, setPendingTargets] = useState< readonly ScopedMobileSettingsTarget[] | null >(null); + const canWriteSettings = + targets.length > 0 && + targets.every((target) => writableEnvironments.has(target.environment.environmentId)); const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets; const hasConnectedSelection = targets.length > 0; const reference = displayTargets[0] ?? null; @@ -165,7 +171,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { reportFailure: true, }); const write = (patch: ServerSettingsPatch) => { - if (writeInFlight.current || !hasConnectedSelection) return; + if ( + writeInFlight.current || + !hasConnectedSelection || + !targets.every((target) => + readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), + ) + ) + return; const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch); if (writes.length === 0) return; writeInFlight.current = true; @@ -182,7 +195,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { }); }; const clearProjectOverrides = () => { - if (writeInFlight.current) return; + if ( + writeInFlight.current || + !targets.every((target) => + readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), + ) + ) + return; const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]); if (writes.length === 0) return; writeInFlight.current = true; @@ -203,7 +222,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, ); const disabled = - pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides); + !canWriteSettings || + pendingWrites > 0 || + !hasConnectedSelection || + (projectSelected && !supportsProjectOverrides); const supportsContinuation = targets.every( (target) => target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true, @@ -245,6 +267,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { )} supportsOverrides={supportsProjectOverrides} pending={pendingWrites > 0} + disabled={!canWriteSettings} onClear={clearProjectOverrides} /> ) : null} diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index 386cdfba062b605b06aabecad8125d0975410ce9..047f28e5fb990e34c4ac3026d498e677cf7b7006 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -1,3 +1,5 @@ +import { AuthSettingsWriteScope } from "@t3tools/contracts"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { AutoSettleDaysField } from "./components/AutoSettleDaysField"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; @@ -61,6 +63,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD function AutoSettleSettingsRows() { const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); + const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope); const projectSelected = selectedProjectKey !== null; const [pendingWrites, setPendingWrites] = useState(0); const writeInFlight = useRef(false); @@ -77,6 +80,9 @@ function AutoSettleSettingsRows() { syncEnvironments, projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null, ); + const canWriteSettings = + syncTargets.length > 0 && + syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId)); const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets; const reference = displayTargets[0] ?? null; @@ -92,7 +98,13 @@ function AutoSettleSettingsRows() { snoozeLimitedThreads?: boolean; }, ) => { - if (writeInFlight.current) return; + if ( + writeInFlight.current || + !syncTargets.every((target) => + readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), + ) + ) + return; const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); if (writes.length === 0) return; writeInFlight.current = true; @@ -127,7 +139,8 @@ function AutoSettleSettingsRows() { (target) => target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true, ); - const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); + const disabled = + !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides); const hasProjectOverrides = projectSelected && syncTargets.some( @@ -136,7 +149,13 @@ function AutoSettleSettingsRows() { target.sources.sidebarAutoSettleAfterDays === "project", ); const clearProjectOverrides = () => { - if (writeInFlight.current) return; + if ( + writeInFlight.current || + !syncTargets.every((target) => + readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope), + ) + ) + return; const writes = planMobileScopedSettingsClear(syncTargets, [ "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", @@ -166,6 +185,7 @@ function AutoSettleSettingsRows() { hasOverrides={hasProjectOverrides} supportsOverrides={supportsProjectOverrides} pending={pendingWrites > 0} + disabled={!canWriteSettings} onClear={clearProjectOverrides} /> ) : null} diff --git a/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx b/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx index b89ab845434af08c8b4db57ec166b9e2e1bcf966..81b21b7b666bd1fda05ee35bdae5df88590e6e80 100644 --- a/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx @@ -8,6 +8,7 @@ export function SettingsProjectOverridesSection(props: { readonly hasOverrides: boolean; readonly supportsOverrides: boolean; readonly pending: boolean; + readonly disabled?: boolean; readonly onClear: () => void; }) { return ( @@ -20,6 +21,7 @@ export function SettingsProjectOverridesSection(props: { diff --git a/apps/mobile/src/features/settings/environment-maintenance.test.ts b/apps/mobile/src/features/settings/environment-maintenance.test.ts index 878f71511890d3dd0aadf5de8e6834849774dbe6..fb90d33b473f62fd7098d7b68de0ed19ba8d5216 100644 --- a/apps/mobile/src/features/settings/environment-maintenance.test.ts +++ b/apps/mobile/src/features/settings/environment-maintenance.test.ts @@ -53,6 +53,29 @@ describe("environment maintenance access", () => { expect(canMaintainEnvironment(null, true)).toBe(false); }); + it.each([ + { scopes: ["environment:maintain"], expected: true }, + { scopes: ["orchestration:operate"], expected: false }, + { scopes: ["providers:manage"], expected: false }, + ] as const)("uses the advertised update permission for $scopes", ({ scopes, expected }) => { + expect( + canMaintainEnvironment( + { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + serverUpdateScope: "environment:maintain", + }, + scopes, + }, + true, + ), + ).toBe(expected); + }); + it("requires remote desktop update support for desktop hosts", () => { expect(supportsEnvironmentUpdate({})).toBe(false); expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); diff --git a/apps/mobile/src/features/settings/environment-maintenance.ts b/apps/mobile/src/features/settings/environment-maintenance.ts index f441066d71744d2ee0269add9649b31e9680af33..02e149a42318eddfb557c4b2183f6c291c57acab 100644 --- a/apps/mobile/src/features/settings/environment-maintenance.ts +++ b/apps/mobile/src/features/settings/environment-maintenance.ts @@ -16,7 +16,8 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect return ( connected && session?.authenticated === true && - session.scopes?.includes(AuthOrchestrationOperateScope) === true + session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === + true ); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index b926db7326e1d3750b325cb2b5502c9937642d23..181c257cbc17569fc3b8dbfb84800869b293141f 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useFontFamily } from "../../lib/useFontFamily"; import { + AuthOrchestrationOperateScope, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, resolveEnvironmentMachineKind, @@ -133,6 +134,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha import { appAtomRegistry } from "../../state/atom-registry"; import { serverEnvironment } from "../../state/server"; import { fileRoutePathSegments } from "../files/filePath"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; function NewTaskWorkspaceIcon(props: { readonly workspaceMode: "local" | "worktree"; @@ -219,6 +221,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; // A project added by cloning exists before its files do: the prompt can be // written meanwhile, but Start waits for the clone. @@ -1214,6 +1222,12 @@ export function NewTaskDraftScreen(props: { if (!selectedProject || !draftKey) { return; } + if ( + environmentConnected && + !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope) + ) { + return; + } const draft = getComposerDraftSnapshot(draftKey); if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return; // Read the latest explicit pick. Antigravity selections stay unchanged @@ -1378,6 +1392,7 @@ export function NewTaskDraftScreen(props: { const canStart = !isImportingContext && !cloneBlocksStart && + taskPermissionReason === null && attachmentBlockReason === null && !modelUnavailable && Boolean(flow.selectedProject) && @@ -1655,6 +1670,10 @@ export function NewTaskDraftScreen(props: { ) : null} {flow.canChooseWorkspace ? {workspaceControls} : null} + {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 bd3cb03b17b61ca86ca6fd11eb46bc63e8d89cc1..9c2ba09425601eae4a72f3ce4379f3be3bcf0e87 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -28,6 +28,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 @@ -200,6 +201,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { icon="stop.fill" variant="danger" className="h-9 w-9" + disabled={!props.canOperateThread} onPress={props.onStopThread} /> ) : null} @@ -342,7 +344,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { label="Submit answers" size="large" tone={props.answers ? "primary" : "secondary"} - disabled={responseDisabled || props.answers === null} + disabled={!props.canOperateThread || responseDisabled || props.answers === null} onPress={() => void props.onSubmit()} /> {props.pendingUserInput.dismissible ? ( @@ -357,6 +359,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ) : null} + {!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 5ea142d08b4d0193b809924131fbde246db2c1e9..298bc07479d763c480ec7faec565177d93f243d2 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -140,6 +140,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; @@ -539,6 +540,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ?? attachmentBlockReason; const canSend = + (props.canOperateThread || props.connectionState !== "connected") && hasContent && !contextImports[composerDraftKey] && !voiceInput.blocksSubmission && @@ -1056,6 +1058,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer accessibilityLabel="Stop agent" icon="stop.fill" variant="danger" + disabled={!props.canOperateThread} onPress={props.onStopThread} /> ) : ( @@ -1150,6 +1153,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 ? ( @@ -1165,6 +1169,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer + + {props.connectionState === "connected" && !props.canOperateThread ? ( + + This connection cannot control this task. You can still edit your draft. + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 1d3beebe9247d901f26dd1e9b8b68917ac9f6934..ea6353578302454f1f9937b46737d2baf3b02de3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -148,6 +148,7 @@ import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; export interface ThreadDetailScreenProps { readonly worktreeSetup?: WorktreeSetupCardProps | null; readonly setupWorkingStartedAt?: string | null; + readonly canOperateThread: boolean; readonly selectedThread: EnvironmentThreadShell; readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; @@ -1297,6 +1298,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread > {props.activePendingApproval ? ( { - if (!selectedThread || composer.interruptibleRunId === null) { + if ( + !selectedThread || + !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) || + composer.interruptibleRunId === null + ) { return; } return interruptThreadTurn({ @@ -988,6 +998,7 @@ function ThreadRouteContent( onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); + const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -917,8 +919,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.`; @@ -1224,6 +1227,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); + if (!canOperateThread) return rowContent(() => {}); + return ( {customSnoozeOpen && ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts index f8d769355f77396b19b9038546c0b5e640595a7d..32f0f3b32a0653a72e1bdec4eed0efad3bf222b0 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -9,6 +9,10 @@ const state = vi.hoisted(() => ({ autoRefresh: async () => {}, refreshingRef: { current: false }, })); +vi.mock("../../state/session", () => ({ + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); vi.mock("react", () => ({ useState: (initial: unknown) => { const index = state.cursor++; diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 144c9d18e8e4b9faf143b21c6285299cff3923f0..0fb8fb9f2691455b94c7b4d4aa4e61d535252dab 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -1,12 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - ProviderConsumeResetCreditOutcome, - ProviderConsumeResetCreditInput, - ServerProvider, - ServerProviderResetCredits, - ServerProviderUsageWindow, - UsageProviderKind, +import { + AuthProvidersManageScope, + type EnvironmentId, + type ProviderConsumeResetCreditOutcome, + type ProviderConsumeResetCreditInput, + type ServerProvider, + type ServerProviderResetCredits, + type ServerProviderUsageWindow, + type UsageProviderKind, } from "@t3tools/contracts"; import { elapsedShare, @@ -24,6 +25,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 { useProviderColors } from "./usageProviders"; @@ -207,6 +209,7 @@ export function ResetCredits(props: { readonly dense?: boolean; }) { const { environmentId, input, credits, now, dense = false } = props; + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -225,6 +228,7 @@ export function ResetCredits(props: { }`; const redeem = async () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); const result = await consume({ environmentId, input }); @@ -241,6 +245,7 @@ export 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.", @@ -257,13 +262,13 @@ export function ResetCredits(props: { {credits.availableCount > 0 ? ( ) : 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 df68a2de3019d3de0f958a345f5b09e2764769cc..b57f9a89cb94a4e4f6125fd59162ec8fc7deb4a5 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, }, @@ -194,6 +196,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); @@ -222,6 +225,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 50b6cf1cee1eb419502f651a96be6fbe1088a907..a5affef80317b84b7b54856b02773cf0315f17da 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 { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { @@ -109,6 +112,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, @@ -255,6 +261,7 @@ async function composerImageAttachmentDataUrl( } async function uploadFileBytes( + environmentId: EnvironmentId, attachment: DraftComposerAttachment, url: string, signal: AbortSignal, @@ -262,6 +269,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; @@ -349,6 +359,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), ); @@ -365,6 +382,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; @@ -393,6 +411,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, @@ -402,6 +421,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), ); @@ -411,6 +431,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 0000000000000000000000000000000000000000..5f727e3304f94635ef11f1296c3a681f8ed2c13c --- /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/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 3377a96c1ecf352cbf9b47caf282c3f2d53fecf4..b6ad84789f4ce53c0b513262f29f4fa123a777a3 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/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 52bcd1bc678a47fd41f904449cac2f8935b5c84d..eab5305ed267f85d8a07fc90cd10d9032c0799ce 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/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, @@ -48,6 +49,7 @@ export function useComposerAttachmentUploadState( } export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; uploadQueue?.retry(environmentId, attachmentId); } @@ -57,6 +59,10 @@ export function useComposerAttachmentUploadWorker() { const queuedMessages = useThreadOutboxMessages(); const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); + const operableEnvironments = useEnvironmentsWithScope( + connectedEnvironments, + AuthOrchestrationOperateScope, + ); const queueRef = useRef | null>(null); useEffect(() => { @@ -117,7 +123,12 @@ export function useComposerAttachmentUploadWorker() { ); const requests = Object.entries(drafts).flatMap(([key, draft]) => { const environmentId = composerDraftEnvironmentId(key, queued, draft); - 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)), @@ -125,5 +136,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 0000000000000000000000000000000000000000..d1ee156235dae47f2c97a60e3164767ae780637d --- /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/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 18086c601852cb868a3f2e2e1652843e7e35ffc4..dffff631d8a000fc48b642e7e83d9c4415bb54e6 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/reactivity"; +import { AsyncResult, Atom } from "effect/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 0000000000000000000000000000000000000000..2fc2fecacc9ef5953752b31dc5f6545d011dbd73 --- /dev/null +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -0,0 +1,260 @@ +import { AuthOrchestrationOperateScope, RuntimeRequestId } from "@t3tools/contracts"; +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { AsyncResult, type Atom } from "effect/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[] }, + pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, + 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", + runtime: null, + latestRun: null, + }, +})); + +vi.mock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => {}, + useRef: (current: A) => ({ current }), + useMemo: (factory: () => A) => factory(), + useState: (initial: A | (() => A)) => [ + typeof initial === "function" ? (initial as () => A)() : initial, + vi.fn(), + ], +})); +vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); +vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); +vi.mock("./queued-run-edit", () => ({ + useQueuedRunEdit: () => null, + getQueuedRunEdit: () => null, +})); +vi.mock("./preferences", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) }; +}); +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("./entities", () => ({ useServerConfigs: () => new Map() })); +vi.mock("./session", () => ({ + readEnvironmentScope: (environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), +})); +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: state.thread, + selectedThreadCreation: null, + selectedEnvironmentRuntime: { + connectionState: state.connectionState, + serverConfig: { + providers: [{ instanceId: "codex", driver: "codex" }], + environment: { capabilities: {} }, + }, + }, + }), +})); +vi.mock("./use-thread-detail", () => ({ + useSelectedThreadProjection: () => null, + useSelectedThreadVisibleTurnItems: () => [], + useSelectedThreadPendingRequests: () => state.pendingRequests, +})); +vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: A) => command })); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) }, + threadEnvironment: { + respondToApproval: state.approve, + respondToUserInput: state.answer, + uploadFeedback: state.feedback, + }, + }; +}); +vi.mock("./use-thread-outbox", async () => { + const { Atom } = await import("effect/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/reactivity"); + return { + composerAttachmentUploadsAtom: Atom.make({}), + composerAttachmentUploadBlockReason: () => null, + composerAttachmentsStillUploading: () => false, + }; +}); +vi.mock("./use-composer-drafts", async () => { + const { Atom } = await import("effect/reactivity"); + return { + composerDraftsAtom: Atom.make({}), + composerContextImportsAtom: Atom.make({}), + getComposerDraftSnapshot: () => state.draft, + clearComposerDraftContent: state.clearDraft, + clearComposerDraft: 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.pendingRequests = { + approvals: [ + { + requestId: RuntimeRequestId.make("approval"), + requestKind: "command", + createdAt: "2026-09-05T00:00:00.000Z", + responseCapability: "live", + }, + ], + userInputs: [], + }; + 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(RuntimeRequestId.make("approval"), "accept"); + expect(state.approve).not.toHaveBeenCalled(); + state.grantedEnvironments.add("secondary"); + await requests.onRespondToApproval(RuntimeRequestId.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.pendingRequests = { + approvals: [], + userInputs: [ + { + requestId: RuntimeRequestId.make("input"), + createdAt: "2026-09-05T00:00:00.000Z", + responseCapability: "live", + dismissible: false, + questions: [state.question], + }, + ], + }; + const requestId = RuntimeRequestId.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.test.tsx b/apps/mobile/src/state/use-selected-thread-requests.test.tsx index 293b5d0ce1a582e0bed1290f1da12ff700b58cbb..9c8ab5a5990f1ff0aa8339c33b2525f7c849c136 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-requests.test.tsx @@ -47,6 +47,7 @@ vi.mock("./entities", () => ({ ], ]), })); +vi.mock("./session", () => ({ readEnvironmentScope: () => true })); vi.mock("./threads", () => ({ threadEnvironment: {} })); vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("./use-thread-selection", () => ({ diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index b33a0d7e43265a596180842ed60a19f528f29652..0c32c6a37c147819a50f988771412b3d8d3c9af9 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -19,6 +19,7 @@ import { type PendingThreadRequests, type ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadPendingRequests } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; +import { readEnvironmentScope } from "./session"; const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] }; @@ -211,7 +213,10 @@ export function useSelectedThreadRequests() { const onRespondToApproval = useCallback( async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { - if (!selectedThreadShell) { + if ( + !selectedThreadShell || + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) + ) { return; } if ( @@ -241,7 +246,8 @@ export function useSelectedThreadRequests() { !selectedThreadShell || !activePendingUserInput || activePendingUserInput.responseCapability === "not_resumable" || - !activePendingUserInputAnswers + !activePendingUserInputAnswers || + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) ) { return; } @@ -318,7 +324,11 @@ export function useSelectedThreadRequests() { // Closes an async question without messaging the agent. const onDismissUserInput = useCallback(async () => { - if (!selectedThreadShell || !activePendingUserInput) { + if ( + !selectedThreadShell || + !activePendingUserInput || + !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 0db48453529c4f80e2fb9fa08bb0f895260ed0ca..12a9999c8a0333681c25870bbdbdf9f854c3d548 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,3 +1,5 @@ +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { readEnvironmentScope } from "./session"; import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; @@ -548,6 +550,12 @@ export function useThreadComposerState() { const onSendMessage = useCallback( async (followUpOverride?: ActiveTurnComposerAction) => { + if ( + selectedThreadShell && + selectedEnvironmentRuntime?.connectionState === "connected" && + !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope) + ) + return null; if (!selectedThreadShell) { return null; } @@ -624,6 +632,8 @@ export function useThreadComposerState() { ? parseCodexFeedbackCommand(text) : null; if (feedbackCommand) { + if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)) + return null; if (thread.activeProviderThreadId === 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 0000000000000000000000000000000000000000..dd347c4e424325ca7a6e7f58947f9e35b7067c58 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -0,0 +1,318 @@ +import { + AuthOrchestrationOperateScope, + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, type Atom } from "effect/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/reactivity"); + return { + serverEnvironment: { configValueAtom: Atom.family((_id: string) => Atom.make(state.config)) }, + }; +}); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/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/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/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.runtime.mockImplementationOnce(async () => { + state.grantedEnvironments.delete("secondary"); + return AsyncResult.success(undefined); + }); + await runDrain(); + expect(state.runtime).toHaveBeenCalledOnce(); + 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 ce725e82de70fa3b7d2ef9e9081455608b995c82..bb2abf326ee90e9913219362527c1f30f6648342 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -13,6 +13,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/reactivity"); return { @@ -203,6 +209,7 @@ function remainingMessages(): ReadonlyArray { beforeEach(() => { appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); + harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -220,6 +227,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 85c6cecb8f708bfd0ce3ea8071ce064aac9b71e3..66744fa0c92765eeab314402c229442547ebe907 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -7,6 +7,7 @@ import { import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { + AuthOrchestrationOperateScope, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -39,6 +40,7 @@ import { recordPendingThreadCreationOutcome, } from "./pending-thread-creation"; import { serverEnvironment } from "./server"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "./session"; import { confirmThreadOutboxMessageQueued, threadOutboxManager, @@ -226,6 +228,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" }; @@ -668,6 +673,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()); @@ -794,6 +803,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), ); @@ -808,6 +820,7 @@ export function useThreadOutboxDrain(): void { const { reportFailure } = makeDeliveryHelpers(queuedMessage); if (settings.runtimeMode !== thread.runtimeMode) { + if (!hasAccess()) return true; const runtimeResult = await setThreadRuntimeMode({ environmentId: queuedMessage.environmentId, input: { @@ -824,6 +837,7 @@ export function useThreadOutboxDrain(): void { } if (settings.interactionMode !== thread.interactionMode) { + if (!hasAccess()) return true; const interactionResult = await setThreadInteractionMode({ environmentId: queuedMessage.environmentId, input: { @@ -842,6 +856,7 @@ export function useThreadOutboxDrain(): void { let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; let deliveryRevision: number; + if (!hasAccess()) return true; try { const preparedResult = await prepareQueuedMessageAttachments( queuedMessage, @@ -861,6 +876,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { + if (!hasAccess()) return true; logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( @@ -888,6 +904,7 @@ export function useThreadOutboxDrain(): void { settings, currentConfig.providers, ); + if (!hasAccess()) return true; const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -922,6 +939,7 @@ export function useThreadOutboxDrain(): void { dispatchMode: queuedMessage.dispatchMode ?? "start", }, }); + if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true; const failure = reportFailure(deliveryResult, "start-turn"); if (failure?.action === "retry") { return false; @@ -952,6 +970,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; @@ -997,6 +1018,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { + if (!hasAccess()) return true; logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( @@ -1024,6 +1046,7 @@ export function useThreadOutboxDrain(): void { settings, currentConfig.providers, ); + if (!hasAccess()) return true; const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: buildProjectThreadStartTurnInput({ @@ -1054,6 +1077,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; @@ -1188,6 +1212,9 @@ export function useThreadOutboxDrain(): void { environmentConnected: environment?.connectionState === "connected", threadBusy: threadRuntimeIsActive(thread?.runtime), }); + 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 @@ -1345,6 +1372,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 61ebba5775f945b4334682f05c16a042fe05ddeb..71ee138ddc3a60f6abc1fd37f73452a4afca5e36 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"; @@ -328,13 +328,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(layerEnvironmentAuth())), ); @@ -535,16 +529,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(layerEnvironmentAuth())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 204e61c189d07a34f2667a72200e7a5e6c4183e4..350ed0305c3148c08071fc61dd9f2f98e862a477 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 13f3336787b2f6106fb0654c11f68463a80df235..eff221a34bd7f51574157890909f599920525e35 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -34,6 +34,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. @@ -88,6 +89,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 0c3327d95f07e1d59bf81bf301cf7b0de61457a9..e1119efdf6df80d28b02e68b80b566ff3861549a 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"; @@ -40,6 +40,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/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 7af90bff80760d13c6d4b43c9825e68536e2193b..f1a616c492745a45fcd58bc637c7ba5fc33de000 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AuthAdministrativeScopes, - type AuthEnvironmentScope, + type AuthCreatePairingCredentialInput, EnvironmentHttpApi, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; @@ -418,7 +418,7 @@ it.live("one-click approves only access the browser session holds the scopes for const clientId = yield* registeredClientId(handler); const params = authorizeParams(clientId); // Signs a browser in through the real route and returns its session cookie. - const browserCookie = (scopes: ReadonlyArray) => + const browserCookie = (scopes: NonNullable) => Effect.gen(function* () { const pairing = yield* auth.issuePairingCredential({ scopes }); const response = yield* handler( diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index db29e3c34ca4684b229ef998d8f68b6dda36b411..5b6a4900567aadc3c45bb1b144687aeb153a7475 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"; @@ -83,13 +84,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"); @@ -168,16 +163,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 3615cb58557b723a0b6f97172499e7d16e5b8b99..cc3695099397baed6a760a8651a1ae4a3fe565fa 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,4 +1,9 @@ import { + AuthEnvironmentMaintainScope, + AuthProvidersManageScope, + AuthSettingsWriteScope, + DEFAULT_SERVER_SETTINGS, + ProviderInstanceId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -28,7 +33,7 @@ describe("RPC authorization scopes", () => { AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( - AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( AuthOrchestrationReadScope, @@ -75,13 +80,13 @@ describe("RPC authorization scopes", () => { AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe( - AuthOrchestrationOperateScope, + AuthProvidersManageScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe( - AuthOrchestrationOperateScope, + AuthProvidersManageScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe( - AuthOrchestrationOperateScope, + AuthProvidersManageScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe( AuthOrchestrationReadScope, @@ -90,7 +95,7 @@ describe("RPC authorization scopes", () => { AuthOrchestrationOperateScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe( - AuthOrchestrationOperateScope, + AuthProvidersManageScope, ); }); @@ -160,9 +165,78 @@ describe("RPC scope middleware", () => { yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip), ).toMatchObject({ _tag: "EnvironmentAuthorizationError", - requiredScope: AuthOrchestrationOperateScope, + requiredScope: AuthEnvironmentMaintainScope, }); expect(handled).toEqual([]); }).pipe(Effect.scoped), ); }); + +describe("settings mutation authorization", () => { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude => + tag !== WS_METHODS.serverUpdateSettings, + ), + ); + const providerInstanceMutation = { + operation: "remove" as const, + instanceId: ProviderInstanceId.make("codex_work"), + }; + + it.effect("allows provider-only mutations while denying mixed settings without their grant", () => + Effect.gen(function* () { + let handled = 0; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => + Effect.sync(() => { + handled++; + return DEFAULT_SERVER_SETTINGS; + }), + ), + RpcAuthorization.layer([AuthProvidersManageScope]), + ), + ), + ); + yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation }); + expect(handled).toBe(1); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: { defaultRuntimeMode: "full-access" }, + providerInstanceMutation, + }).pipe(Effect.flip), + ).toMatchObject({ requiredScope: AuthSettingsWriteScope }); + expect(handled).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("does not let a settings grant create or remove providers", () => + Effect.gen(function* () { + let handled = false; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverUpdateSettings, () => + Effect.sync(() => { + handled = true; + return DEFAULT_SERVER_SETTINGS; + }), + ), + RpcAuthorization.layer([AuthSettingsWriteScope]), + ), + ), + ); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: {}, + providerInstanceMutation, + }).pipe(Effect.flip), + ).toMatchObject({ requiredScope: AuthProvidersManageScope }); + expect(handled).toBe(false); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3cce8fbe0b21103d90762eb70243f7a36e64e9ab..355668e1b196b060903cfa81c7d13ee53e0b436a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,6 +1,13 @@ import { + CLIENT_GUARDED_RPC_SCOPES, type DeviceListInput, AuthAccessReadScope, + ServerSettingsPatch, + ProviderInstanceMutation, + requiredScopesForServerSettingsPatch, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -15,6 +22,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; import type * as RpcGroup from "effect/rpc/RpcGroup"; @@ -26,6 +34,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; * runtime failure. */ export const RPC_REQUIRED_SCOPES = { + ...CLIENT_GUARDED_RPC_SCOPES, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, @@ -41,61 +50,56 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope, [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.chatGptReconnectProfile]: AuthOrchestrationOperateScope, - [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope, - [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope, - [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthRespond]: 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.chatGptReconnectProfile]: AuthProvidersManageScope, + [WS_METHODS.chatGptImportProfile]: AuthProvidersManageScope, + [WS_METHODS.chatGptHandoffSubscribe]: AuthProvidersManageScope, + [WS_METHODS.codexAuthCallbackSubscribe]: AuthProvidersManageScope, + [WS_METHODS.providerAuthRespond]: 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.serverSearchAcpRegistry]: AuthOrchestrationReadScope, - [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthOrchestrationOperateScope, - [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthOrchestrationOperateScope, + [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, + [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope, + [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope, [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope, [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope, [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope, [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope, - [WS_METHODS.serverSetAcpRegistryProvider]: AuthOrchestrationOperateScope, - [WS_METHODS.serverDisableAcpRegistryProvider]: AuthOrchestrationOperateScope, - [WS_METHODS.serverLogoutAcpRegistry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope, + [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, + [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetHostResources]: 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.scheduledTasksList]: AuthOrchestrationReadScope, [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope, - [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, - [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, - [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, - [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, - [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope, // Delivery logs hold request bodies, so they need the same scope as the URL. [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope, @@ -195,8 +199,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, - [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, - [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope, + [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope, [WS_METHODS.deviceList]: AuthOrchestrationReadScope, [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, @@ -227,13 +231,30 @@ export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => requiredScope, }); +const SettingsUpdate = Schema.Struct({ + patch: ServerSettingsPatch, + providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation), +}); + +const requiredScopesForSettingsUpdate = (payload: unknown) => { + const input = Schema.decodeUnknownSync(SettingsUpdate)(payload); + const scopes = requiredScopesForServerSettingsPatch(input.patch); + if (input.providerInstanceMutation === undefined) return scopes; + // An atomic provider mutation carries an empty patch unless it also changes settings. + return Object.values(input.patch).every((value) => value === undefined) + ? [AuthProvidersManageScope] + : [...new Set([...scopes, AuthProvidersManageScope])]; +}; + /** Authorizes every RPC on one connection against that connection's session scopes. */ export const layer = (scopes: ReadonlyArray) => - Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => { - const requiredScope = requiredScopeForRpcMethod(rpc._tag); - return scopes.includes(requiredScope) - ? effect - : Effect.fail(rpcAuthorizationError(requiredScope)); + Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { + const requiredScopes = + rpc._tag === WS_METHODS.serverUpdateSettings + ? requiredScopesForSettingsUpdate(payload) + : [requiredScopeForRpcMethod(rpc._tag)]; + const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); + return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope)); }); /** Retrying can install or restart tools even though ordinary listing is readable. */ diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 330d55ba43d8ad3e57bdf2dd40c98ac5babb3e5a..f7d3e7e1f5ace25f662b9bc4b53b20a7a0951769 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"; @@ -346,13 +346,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(layerSessionStore(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ae6ec0076222a9cdea826afbf12733a220a66ecf..1a74467edfc880e8ee8487f28329c3bdbf59494e 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, @@ -366,6 +369,9 @@ export const layer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.test.ts b/apps/server/src/observability/RpcInstrumentation.test.ts index 32baf29d068cadfc6ce0e082f00624eb6a22d407..4ac5c52ee031ae95d888bf8d31e43e9c1cc355c8 100644 --- a/apps/server/src/observability/RpcInstrumentation.test.ts +++ b/apps/server/src/observability/RpcInstrumentation.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import { - AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, AuthOrchestrationReadScope, type AuthEnvironmentScope, ScheduledTaskError, @@ -319,7 +319,7 @@ describe("WS RPC instrumentation middleware", () => { message: Option.none(), }).pipe(Effect.withSpan("signalProcess.child")), ), - connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + connectionMiddleware([AuthOrchestrationReadScope, AuthEnvironmentMaintainScope]), ), ), ); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index bf3e9906b16ce30ae400bfbb76d65201971c7243..3e461f5f1a5b1355e55036b683fcf68affcdb452 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -2,7 +2,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, it, assert } from "@effect/vitest"; +import { describe, it, assert, expect } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -2077,6 +2077,98 @@ it.layer( ]); }); + it.effect("shares one in-flight pass between concurrent untargeted refreshes", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshCalls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const instances = [ + { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.gen(function* () { + yield* Ref.update(refreshCalls, (count) => count + 1); + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + return provider; + }), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }, + ] satisfies ReadonlyArray; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-shared-refresh-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const first = yield* registry.refresh().pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const second = yield* registry.refresh().pipe(Effect.forkChild); + yield* Effect.yieldNow; + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + yield* Deferred.succeed(releaseProbe, undefined); + expect(yield* Fiber.join(first)).toEqual([expect.objectContaining(provider)]); + expect(yield* Fiber.join(second)).toEqual([expect.objectContaining(provider)]); + assert.strictEqual(yield* Ref.get(refreshCalls), 1); + + yield* registry.refresh(); + assert.strictEqual(yield* Ref.get(refreshCalls), 2); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 898bb955e5eded8e90c5b828f02de5877ff5638e..65c56525d329402ca6f6cc47b735480d0b5be1ca 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -36,10 +36,12 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; @@ -701,12 +703,50 @@ export const layer = Layer.effect( ); }); + type RefreshAllCompletion = Deferred.Deferred>; + const refreshAllInFlightRef = yield* Ref.make>( + Option.none(), + ); + + // Untargeted refreshes probe every live source, so any read-scoped client + // can request them. Concurrent callers share one in-flight pass instead of + // each starting their own set of provider processes. const refreshAll = Effect.fn("refreshAll")(function* () { - const sources = yield* getLiveSources; - return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { - concurrency: "unbounded", - discard: true, - }).pipe(Effect.andThen(Ref.get(providersRef))); + const claimed = yield* Ref.modify( + refreshAllInFlightRef, + ( + inFlight, + ): readonly [ + { readonly owner: boolean; readonly completion: RefreshAllCompletion }, + Option.Option, + ] => { + if (Option.isSome(inFlight)) { + return [{ owner: false, completion: inFlight.value }, inFlight]; + } + const completion = Deferred.makeUnsafe>(); + return [{ owner: true, completion }, Option.some(completion)]; + }, + ); + if (claimed.owner) { + // Detached so an interrupted first caller cannot cancel the pass that + // other callers are already awaiting. + yield* getLiveSources.pipe( + Effect.flatMap((sources) => + Effect.forEach(sources, (source) => refreshOneSource(source), { + concurrency: "unbounded", + discard: true, + }), + ), + Effect.andThen(Ref.get(providersRef)), + Effect.onExit((exit) => + Ref.set(refreshAllInFlightRef, Option.none()).pipe( + Effect.andThen(Deferred.done(claimed.completion, exit)), + ), + ), + Effect.forkDetach, + ); + } + return yield* Deferred.await(claimed.completion); }); const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 44ce0767e4b40027a576206da055d9a986c8d7a9..051f053316136f437a741da550b09563332fadc8 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -686,6 +686,91 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + // Only driver-keyed instances are fallback candidates; a custom instance id + // is not one, so the selection stays put until the user changes it. + it.effect.each(["codex"])( + "falls back to enabled instance %s after disabling the selection", + (fallbackId) => + 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(layerServerSettings())), + ); + + 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(layerServerSettings())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 8d9b919db05fd95d3195b479f5350d999a254c1e..5b5244697066d215f439431edfa4a2ee5cdfb4ed 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"; import { hasCloudPublicConfig } from "./publicConfig"; @@ -44,13 +49,24 @@ function targetKey(target: CloudLinkTarget): string { } 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" @@ -65,7 +81,7 @@ export function usePrimaryCloudLinkState() { ); // Builds without T3 Connect have no link to read; skip the request. const atom = - target && hasCloudPublicConfig() + target && hasCloudPublicConfig() && canReadRelay ? primaryCloudLinkStateAtom(targetKey(target)) : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); @@ -79,7 +95,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 0000000000000000000000000000000000000000..5ee48b5f72252b063fceb834e12bf5eb70505eaf --- /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/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 7301595999692357696c49f2f7238726b2850be7..e39feb697762ea856e7adfea2cbc8a024d4573df 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 { EnvironmentId, 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 { @@ -87,8 +89,31 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Local environment is not ready yet.")); return false; } - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const environmentId = EnvironmentId.make(target.environmentId); + const canManageLink = () => { + if ( + !readEnvironmentScope(environmentId, AuthRelayReadScope) || + !readEnvironmentScope(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 — @@ -109,6 +134,8 @@ export function useCloudLinkController() { return false; } } else { + const currentLinkState = readLinkState(); + if (currentLinkState === null) return false; if (tokenResult._tag === "Failure") { reportUpdateFailure(squashAtomCommandFailure(tokenResult)); return false; @@ -118,7 +145,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, @@ -132,6 +160,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 0000000000000000000000000000000000000000..750d013e0ceee6f60b2a12e527ff601792ee6fed --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.permissions.test.tsx @@ -0,0 +1,299 @@ +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/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 { + // Asset atoms read these at module scope; the markdown tests only exercise + // the task grant. + environmentSession: { + sessionStateAtom: () => ({}), + preparedConnectionValueAtom: () => ({}), + }, + 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", () => ({ + readEnvironmentSupportsServerBrowser: () => false, + useServerConfigs: () => new Map([[threadRef.environmentId, serverConfig]]), + 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", () => ({ + resolvePullRequestPreviewTarget: () => null, + findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0], + matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) => + candidate.url === href, + parseChangeRequestUrl: (href: string) => + href === linkedPullRequest.url + ? { + host: "github.com", + 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 7f655acb1cc30a6bbeee7447f7421ec3318609ce..b1ceb33062c8ba226dabe2cfa3f772e1c6c3c785 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -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"; @@ -38,10 +38,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 5389cae83d675e6020eaa94c34fffd6fd927328a..de85ab8b0712d93db3021571690563fdbe6785c5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,5 @@ import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, @@ -171,7 +172,7 @@ 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 { useAtomQueryRunner } from "../state/use-atom-query-runner"; @@ -2516,12 +2517,11 @@ function useChatMarkdownState({ }); const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); 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, clickedImage?: HTMLImageElement | null) => { @@ -2591,6 +2591,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 }, @@ -2687,6 +2694,7 @@ function useChatMarkdownState({ const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; + if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return; await pullRequestLinking.changeLink(threadRef, href, linked); }, [linkedThreadPullRequestFor, pullRequestLinking, threadRef], @@ -2878,6 +2886,7 @@ function useChatMarkdownState({ const componentState = useMemo( () => ({ + canOperateHost, cwd, diffThemeName, environmentId, @@ -2909,6 +2918,7 @@ function useChatMarkdownState({ updateThreadPullRequestLink, }), [ + canOperateHost, cwd, diffThemeName, environmentId, @@ -3063,6 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = { }, a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { const { + canOperateHost, cwd, environmentId, imageBaseDir, @@ -3203,8 +3214,9 @@ const CHAT_MARKDOWN_COMPONENTS = { event.stopPropagation(); const api = readLocalApi(); if (!api) return; - const threadLinkAction = - linkedThreadPullRequestFor(href) !== null + const threadLinkAction = !canOperateHost + ? undefined + : linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" : resolveThreadPullRequest(href) === null ? undefined diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e705a42d5976f4e231789d515365799ed4df1873..bc383266d5c769f509f148dd821c0a0bae0486d8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -48,12 +48,15 @@ import { } from "../questionAttachments"; import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; import { + AuthOrchestrationOperateScope, + AuthSettingsWriteScope, type AssistantCitation, type ChatFileAttachment, CommandId, DEFAULT_MODEL, isProviderNativeSubagentThread, type ChatAttachment as ContractChatAttachment, + EnvironmentAuthorizationError, type EnvironmentId, type MessageId, type ModelSelection, @@ -285,9 +288,10 @@ import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; import { WizardPopup } from "./ui/wizard"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -555,8 +559,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, InlineButton } from "./ui/button"; import { @@ -1535,6 +1544,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 handleNewThread = useNewThreadHandler(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); @@ -1562,46 +1572,48 @@ export default function ChatView(props: ChatViewProps) { 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 = useAtomCommand(threadEnvironment.setInteractionMode, { + const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); - const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); - const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, { + const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, { reportFailure: false, }); - const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, { + reportFailure: false, + }); + const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); - const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { + const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, { label: "load earlier thread history", 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 dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { reportFailure: false, }); - const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { + const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { @@ -3228,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) { providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ?? activeProviderStatus; const supportsConversationRollback = + canOperateThread && conversationProviderStatus !== null && conversationProviderStatus.supportsConversationRollback !== false; const phase = derivePhase(activeRuntime); @@ -4065,6 +4078,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 remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId); const showOpenInPicker = shouldShowOpenInPicker({ @@ -4497,12 +4513,14 @@ export default function ChatView(props: ChatViewProps) { const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); }, [composerRef]); - const canInterruptRunningThread = deriveCanInterruptRunningThread( - activeThread !== undefined, - activeRuntime, - ); + const canInterruptRunningThread = + canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); const onInterrupt = useCallback(async () => { - if (!activeThread) return; + if ( + !activeThread || + !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope) + ) + return; const result = await interruptThreadTurn({ environmentId, input: { threadId: activeThread.id }, @@ -4997,9 +5015,37 @@ export default function ChatView(props: ChatViewProps) { projectCwd: string; previousScripts: ReadonlyArray; nextScripts: ReadonlyArray; - keybinding?: string | null; + keybinding: NewProjectScriptInput["keybinding"]; keybindingCommand: KeybindingCommand | null; }): 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 updateResult = mapAtomCommandResult( await updateProjectScriptSettings({ environmentId, @@ -5028,10 +5074,19 @@ export default function ChatView(props: ChatViewProps) { return updateResult; } - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); + if (!changesKeybinding) return updateResult; + if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSettingsWriteScope, + message: isDeletingScript + ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed." + : "The script was saved, but this connection can no longer change keyboard shortcuts.", + }), + ), + ); + } if (!isElectron) return updateResult; @@ -6991,7 +7046,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 @@ -7000,7 +7055,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 { @@ -7022,13 +7081,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 { @@ -7175,7 +7238,8 @@ export default function ChatView(props: ChatViewProps) { const isStoppingBackgroundWork = stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; const handleStopBackgroundWork = useCallback(async () => { - if (!activeThread) return; + if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; const requestKey = `${environmentId}:${activeThread.id}`; setStoppingBackgroundWorkKey(requestKey); const result = await interruptThreadTurn({ @@ -7377,7 +7441,7 @@ export default function ChatView(props: ChatViewProps) { + + + ); +} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3e1356f988a120111e3367bf8b47c3580d46bf16..55db8a49c9d047d97dbeba8dc20663350923485f 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -45,7 +45,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, @@ -96,7 +98,12 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; +import { + ORCHESTRATION_OPERATE_DENIED_MESSAGE, + useOrchestrationCommand, +} from "../state/use-orchestration-command"; import { previewEnvironment } from "../state/preview"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { legacyProjectCwdPreferenceKey, resolveProjectExpanded, @@ -357,6 +364,18 @@ interface SidebarThreadRowProps { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; } +function checkTaskPermission(environmentId: EnvironmentId): boolean { + if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Action unavailable", + description: ORCHESTRATION_OPERATE_DENIED_MESSAGE, + }), + ); + return false; +} + const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, @@ -385,6 +404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onFileDropThreads, thread, } = props; + const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const [isFileDragOver, setIsFileDragOver] = useState(false); @@ -485,10 +505,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.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(() => { @@ -517,6 +538,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); 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; @@ -531,7 +553,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP 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) => { @@ -685,12 +707,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP (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) => { @@ -775,7 +798,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} {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 5bffde323eb309ab17f841da0f85ea660cb734ae..b1f6bf6d6bdc2823b19f548098a85932574c8ab9 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,9 +1,13 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; -import type { - ProjectScript, - ResolvedKeybindingsConfig, - T3ProjectFileScript, +import { + AuthSettingsWriteScope, + type EnvironmentId, + type ProjectScript, + type T3ProjectFileScript, + type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -13,6 +17,8 @@ import React, { 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, @@ -55,10 +61,10 @@ interface ProjectScriptsControlProps { displayMode?: "toolbar" | "panel"; presentation?: "toolbar" | "menu"; onRequestMenuClose?: () => void; + 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; @@ -73,9 +79,9 @@ export default function ProjectScriptsControl({ displayMode = "toolbar", presentation = "toolbar", onRequestMenuClose, + environmentId, scripts, fileScripts = NO_FILE_SCRIPTS, - keybindings, preferredScriptId = null, onRunScript, onAddScript, @@ -85,6 +91,9 @@ export default function ProjectScriptsControl({ const isPanel = displayMode === "panel"; const ActionGroup = isPanel ? "div" : Group; const panelAnchorRef = React.useRef(null); + const keybindings = + useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? + DEFAULT_RESOLVED_KEYBINDINGS; const [actionsMenuOpen, setActionsMenuOpen] = useState({ presentation, scripts: false, @@ -138,7 +147,7 @@ export default function ProjectScriptsControl({ icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, - keybinding: null, + ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}), previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, }; @@ -459,6 +468,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 27df0460f50096e309f4b4db103dff0d5a1c5f52..2cd406045d19874ed0b1abfa2f960fa4f2b17c23 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 3fca1d748ca71ce69de5614ac973e99facbc182e..98b3e71dac2122acbc381fc7cfc01bd184b5af33 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 ServerProvider, type ProviderInstanceId } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + type ServerProvider, + 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 { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; 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; @@ -100,6 +110,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, }); @@ -178,11 +192,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; } @@ -199,7 +217,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; @@ -219,6 +242,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, @@ -260,44 +296,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 b065ef8c83491a38b71945d59d127c2efc3787f1..385e12af7dfc0917b27b5ee65842d3d38e48fb49 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,8 +1,9 @@ import { act, type ReactElement } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; -import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts"; +import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -11,6 +12,8 @@ const testState = vi.hoisted(() => ({ toast: vi.fn(), clipboard: vi.fn(), continueThreadsAfterServerUpdate: false, + session: null as AsyncResult.AsyncResult | null, + sessionAtom: Symbol("session"), })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -27,6 +30,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") }, })); @@ -50,6 +60,8 @@ import { type ServerUpdateTarget, } from "./ServerUpdateAction"; +const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); + type ActionElement = ReactElement<{ readonly onClick?: () => void; }>; @@ -68,12 +80,86 @@ 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.clipboard.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.each([ diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index d828e929f932ea0dc13d4caaaea3b3035005f0d5..0133619157949ef258ff8c7305c43f06710dc016 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/reactivity"; +import { environmentSession } from "~/state/session"; import type { EnvironmentId, ServerInstallation, @@ -15,6 +19,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment, updateOutdatedServer } from "~/state/server"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; @@ -60,7 +65,11 @@ function useServerUpdate() { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { const { environmentId, serverLabel, selfUpdate, targetVersion } = target; - if (pendingUpdateEnvironmentIds.has(environmentId)) return; + if ( + !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) || + pendingUpdateEnvironmentIds.has(environmentId) + ) + return; pendingUpdateEnvironmentIds.add(environmentId); try { const result = await updateServer({ @@ -151,6 +160,17 @@ export function ServerUpdatesAction({ ); } +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 @@ -204,6 +224,8 @@ export function ServerUpdateAction({ appearance = "button", }: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); + const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, @@ -232,7 +254,10 @@ export function ServerUpdateAction({ }); const handleUpdate = async () => { - if (pendingUpdateEnvironmentIds.has(environmentId)) { + if ( + !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || + pendingUpdateEnvironmentIds.has(environmentId) + ) { return; } if (isDesktopAppUpdate) { @@ -247,6 +272,7 @@ export function ServerUpdateAction({ return; } } + if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return; await update({ environmentId, serverLabel, @@ -289,6 +315,7 @@ export function ServerUpdateAction({ variant="ghost-muted" className={className} aria-label={`${actionLabel} for ${serverLabel}`} + disabled={manualCommand === null && !canUpdate} onClick={onClick} /> } @@ -301,7 +328,13 @@ export function ServerUpdateAction({ } return ( - ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ee9c313cbec740e8c4ee9bf763bc22185b73d9bf..b3be7790bce47ba1baf2239108c2b771abb12daa 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -38,6 +38,7 @@ import type { SnapShotSource, } from "@t3tools/contracts"; import { + AuthOrchestrationOperateScope, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -1130,6 +1131,7 @@ import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; +import { readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -1348,6 +1350,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; + canOperateThread: boolean; activeContextWindow: ContextWindowSnapshot | null; reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; @@ -1397,6 +1400,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( ) : null} { - if (noProviderAvailable || isSendDisabled) { + if ( + noProviderAvailable || + isSendDisabled || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { event?.preventDefault(); return; } @@ -4210,6 +4222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, attachmentTargetKey, blurMobileComposerAfterSend, + environmentId, isSendDisabled, noProviderAvailable, onSend, @@ -4245,6 +4258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [phase, settings.followUpBehavior, submitComposer]); const compactThreadContext = useCallback(() => { if ( + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || compactDisabled || noProviderAvailable || activePendingApproval !== null || @@ -4261,6 +4275,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingApproval, activeThreadId, compactDisabled, + composerDraftTarget, + environmentId, isConnecting, isSendBusy, noProviderAvailable, @@ -6650,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) 0 ? ( } @@ -7178,6 +7199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) draftTarget: attachmentDraftTarget, }) } + disabled={!canOperateThread} aria-label={`Retry upload for ${file.name}`} /> } @@ -7256,6 +7278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) draftTarget: attachmentDraftTarget, }) } + disabled={!canOperateThread} aria-label={`Retry upload for ${file.name}`} /> } @@ -7408,6 +7431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) > | undefined; onRespondToApproval: ( requestId: RuntimeRequestId, @@ -32,6 +33,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro requestId, isResponding, canRespond, + disabled = false, options = DEFAULT_APPROVAL_OPTIONS, onRespondToApproval, }: ComposerPendingApprovalActionsProps) { @@ -50,9 +52,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro key={option.decision} size="xs" variant={option.decision === "accept" ? "default" : "outline"} - disabled={isResponding || !canRespond} + disabled={disabled || isResponding || !canRespond} aria-description={option.warning} - onClick={() => void onRespondToApproval(requestId, option.decision)} + onClick={() => { + if (!disabled && !isResponding) { + void onRespondToApproval(requestId, option.decision); + } + }} > {option.warning ? : null} {option.label} @@ -70,7 +76,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro {moreOptions.length > 0 ? ( } > @@ -80,9 +86,12 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro const item = ( void onRespondToApproval(requestId, option.decision)} + onClick={() => { + if (!disabled && !isResponding) + void onRespondToApproval(requestId, option.decision); + }} variant="ghost" className="mb-1 last:mb-0" > 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 0000000000000000000000000000000000000000..f81ed6702600005cf4626ca07d7d4dd019054096 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx @@ -0,0 +1,93 @@ +import { RuntimeRequestId } 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 onDismiss = vi.fn(); + const prompt = { + requestId: RuntimeRequestId.make("scoped-answer"), + createdAt: "2026-09-05T00:00:00.000Z", + dismissible: false, + responseCapability: "live" as const, + 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 28124ce74aad1abc356eeb3864ef48996c232b7d..483eeb57a6e3fe42d704737713b1b851c92e5697 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: RuntimeRequestId[]; answers: Record; questionIndex: number; @@ -22,6 +23,7 @@ interface PendingUserInputPanelProps { export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, + disabled = false, respondingRequestIds, answers, questionIndex, @@ -37,6 +39,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn ; questionIndex: number; @@ -66,7 +71,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }) { // Message-mode requests remain answerable after their provider turn ends. const canRespond = prompt.responseCapability !== "not_resumable"; - const responseDisabled = isResponding || !canRespond; + const responseDisabled = disabled || isResponding || !canRespond; const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef(null); @@ -88,6 +93,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; @@ -120,6 +132,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => { + if (disabled || isResponding) return; if (activeQuestion?.multiSelect) { onToggleOption(questionId, optionValue); return; @@ -134,7 +147,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 @@ -252,8 +265,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 = ( <> @@ -280,7 +293,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : ( - - + Delete action "{name}"? @@ -463,9 +491,13 @@ export function ProjectScriptEditorDialog({ }>Cancel - diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index d112af1fed03444ce5f6606dd43dffb8dfe6424b..d643cfe27765be879c17213492977f122e550c92 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { visitElements } from "../../test/reactElementTree"; import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +const actions = vi.hoisted(() => ({ + update: vi.fn(), + toast: vi.fn(), + onOpenChange: vi.fn(), + canManageProviders: true, +})); + const settingsHooks = vi.hoisted(() => ({ read: vi.fn(() => ({ providerInstances: {} })), mutate: vi.fn(), @@ -35,6 +42,15 @@ vi.mock("../../hooks/useSettings", () => ({ usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation, })); +vi.mock("../../state/session", async (importOriginal) => { + const actual = await importOriginal(); + const hasScope = (environmentId: EnvironmentId, scope: string) => + environmentId === "remote-device" && scope === "providers:manage" && actions.canManageProviders; + return { ...actual, useEnvironmentScope: hasScope, readEnvironmentScope: hasScope }; +}); + +vi.mock("../ui/toast", () => ({ toastManager: { add: actions.toast } })); + import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; const remoteEnvironmentId = EnvironmentId.make("remote-device"); @@ -81,9 +97,45 @@ async function selectPreparedAcp() { ); } +function renderDialog() { + hooks.beginRender(); + return AddProviderInstanceDialog({ + open: true, + environmentId: remoteEnvironmentId, + environmentLabel: "Remote device", + onOpenChange: actions.onOpenChange, + }); +} + +function button(dialog: unknown, label: string) { + const element = visitElements( + dialog, + (entry) => entry.props.children === label && typeof entry.props.onClick === "function", + ); + if (!element) throw new Error(`Missing button: ${label}`); + return element; +} + +function prepareInstance() { + let dialog = renderDialog(); + (button(dialog, "Configure manually").props.onClick as () => void)(); + dialog = renderDialog(); + const label = visitElements(dialog, (entry) => entry.props.placeholder === "e.g. Work"); + if (!label) throw new Error("Missing instance label input."); + (label.props.onChange as (event: { target: { value: string } }) => void)({ + target: { value: "Work" }, + }); + dialog = renderDialog(); + (button(dialog, "Next").props.onClick as () => void)(); + return renderDialog(); +} + describe("AddProviderInstanceDialog environment routing", () => { beforeEach(() => { hooks.reset(); + actions.canManageProviders = true; + actions.toast.mockReset(); + actions.onOpenChange.mockReset(); settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} }); settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} }); settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate); @@ -321,4 +373,53 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(onOpenChange).not.toHaveBeenCalled(); }); + + it("adds an instance with the selected environment's provider grant alone", async () => { + const dialog = prepareInstance(); + (button(dialog, "Add instance").props.onClick as () => void)(); + + await Promise.resolve(); + expect(settingsHooks.mutate).toHaveBeenCalledWith({ + operation: "create", + instanceId: "codex_work", + instance: { + driver: "codex", + enabled: true, + displayName: "Work", + config: { setupMode: "existing" }, + }, + }); + expect(actions.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Provider instance added" }), + ); + expect(actions.onOpenChange).toHaveBeenCalledWith(false); + }); + + it("rejects a queued save after the provider grant is revoked", () => { + const dialog = prepareInstance(); + const save = button(dialog, "Add instance").props.onClick as () => void; + actions.canManageProviders = false; + save(); + + expect(settingsHooks.mutate).not.toHaveBeenCalled(); + expect(actions.toast).not.toHaveBeenCalled(); + expect(actions.onOpenChange).not.toHaveBeenCalled(); + expect(button(renderDialog(), "Add instance").props.disabled).toBe(true); + }); + + it("keeps a denied draft available when the provider grant arrives", async () => { + actions.canManageProviders = false; + let dialog = prepareInstance(); + (button(dialog, "Add instance").props.onClick as () => void)(); + expect(settingsHooks.mutate).not.toHaveBeenCalled(); + expect(actions.toast).not.toHaveBeenCalled(); + + actions.canManageProviders = true; + dialog = renderDialog(); + expect(button(dialog, "Add instance").props.disabled).toBe(false); + (button(dialog, "Add instance").props.onClick as () => void)(); + await Promise.resolve(); + expect(settingsHooks.mutate).toHaveBeenCalledOnce(); + expect(actions.onOpenChange).toHaveBeenCalledWith(false); + }); }); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 0d137c89dec31b4756aaff21346d599a4d601706..b329d6ef134bd0d39fbef53d060ba10c166b1665 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -9,6 +9,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { DEFAULT_UNIFIED_SETTINGS, type AcpRegistrySearchAgent, + AuthProvidersManageScope, ProviderInstanceId, ProviderDriverKind, type EnvironmentId, @@ -24,6 +25,7 @@ import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { Button } from "../ui/button"; import { ChatGptConnectionButton } from "./ChatGptConnectionButton"; import { Dialog } from "../ui/dialog"; @@ -113,6 +115,7 @@ export function AddProviderInstanceDialog({ }: AddProviderInstanceDialogProps) { const settings = useEnvironmentSettings(environmentId); const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const [wizardStep, setWizardStep] = useState(0); const [addingChatGptAccount, setAddingChatGptAccount] = useState(false); @@ -308,6 +311,7 @@ export function AddProviderInstanceDialog({ }; const handleSave = async () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; if (isSaving || createdInstanceId) return; setHasAttemptedSubmit(true); if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return; @@ -694,7 +698,11 @@ export function AddProviderInstanceDialog({ Next ) : ( - @@ -1180,36 +1223,44 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio size="xs" variant="outline" disabled={isCreatingPairingLink} - onClick={() => setPairingScopes([...AuthStandardClientScopes])} + onClick={() => + setPairingScopes( + AuthStandardClientScopes.filter((scope) => + delegatableScopes.includes(scope), + ), + ) + } > Standard
- {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => ( - - ))} + + ), + )}
- {pairingScopes.length === 0 ? ( + {selectedScopes.length === 0 ? (

Select at least one permission.

- ) : pairingScopes.includes(AuthAccessWriteScope) ? ( + ) : selectedScopes.includes(AuthAccessWriteScope) ? (

This client can create or revoke access for other devices.

@@ -1225,7 +1276,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio Cancel - } - /> - Refresh provider status - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider" - > - - - } - /> - Add provider - - - )} + + void refreshProviders()} + > + + Refresh provider status + + {isRefreshingProviders ? ( + "Refreshing providers" + ) : ( + + )} + + + } + /> + Refresh provider status + + {!readOnly ? ( + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider" + > + + + } + /> + Add provider + + ) : null} } > @@ -1318,7 +1272,10 @@ export function EnvironmentProviderSettings({ description="Refresh provider status, versions, and models in the background. Set to 0 to disable." resetAction={ providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - + @@ -1336,11 +1293,11 @@ export function EnvironmentProviderSettings({ } control={
- {isAddInstanceDialogOpen ? ( + {isAddInstanceDialogOpen && !readOnly ? ( ; onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; }) { @@ -532,16 +536,21 @@ function ProcessActions({ } const isSignaling = signalingKeys.has(processIdentityKey(process)); return ( - onSignal(process, signal)} /> + onSignal(process, signal)} + /> ); } function ProcessTable({ processes, + canMaintainEnvironment, signalingKeys, onSignal, }: { processes: ReadonlyArray; + canMaintainEnvironment: boolean; signalingKeys: ReadonlySet; onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; }) { @@ -650,6 +659,7 @@ function ProcessTable({ @@ -820,6 +830,7 @@ export function ResourceTelemetryDiagnostics({ const [windowMs, setWindowMs] = useState(15 * 60_000); const selectedWindow = HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1]; + const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); const telemetry = useResourceTelemetry(environmentId); const retryTelemetry = telemetry.retry; const history = useResourceTelemetryHistory( @@ -848,7 +859,11 @@ export function ResourceTelemetryDiagnostics({ const signalProcess = useCallback( async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => { const targetEnvironmentId = environmentIdRef.current; - if (targetEnvironmentId === null) return; + if ( + targetEnvironmentId === null || + !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) + ) + return; const identityKey = processIdentityKey(process); if (signalingKeysRef.current.has(identityKey)) return; const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey); @@ -882,7 +897,10 @@ export function ResourceTelemetryDiagnostics({ return; } } - if (environmentIdRef.current !== targetEnvironmentId) { + if ( + environmentIdRef.current !== targetEnvironmentId || + !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) + ) { clearSignaling(); return; } @@ -924,6 +942,12 @@ export function ResourceTelemetryDiagnostics({ ); const retryCollector = useCallback(() => { + const environmentId = environmentIdRef.current; + if ( + environmentId === null || + !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) + ) + return; setIsRetrying(true); void retryTelemetry() .catch((error: unknown) => { @@ -1253,6 +1277,7 @@ export function ResourceTelemetryDiagnostics({
diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index d82b6f163694a32c831913af464fc873a9471106..34c3896d87fca46edca7ee1e872461e909b3d64a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -59,6 +59,8 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { readEnvironmentScope } from "~/state/session"; import { useSettingsScope } from "./SettingsScopeContext"; import { WEBHOOK_SIGNATURE_DEFAULTS, @@ -246,6 +248,9 @@ export function ScheduledTasksSettings(target: { setEditor({ environmentId, task }); }, []); const defaultEnvironment = environment ?? connectedEnvironments[0]; + const canCreate = useAtomValue( + serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), + ); return ( defaultEnvironment && setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) @@ -404,6 +409,9 @@ function ScheduledTaskRow({ readonly task: ScheduledTask; readonly onEdit: () => void; }) { + const canOperate = useAtomValue( + serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), + ); const [busy, setBusy] = useState(false); const [deliveriesOpen, setDeliveriesOpen] = useState(false); const isWebhook = task.schedule.type === "webhook"; @@ -417,7 +425,7 @@ function ScheduledTaskRow({ label: "scheduled task delete", }); const act = async (action: "toggle" | "run" | "delete") => { - if (busy) return; + if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; setBusy(true); const result = action === "toggle" @@ -462,7 +470,7 @@ function ScheduledTaskRow({
void act("toggle")} /> @@ -472,7 +480,7 @@ function ScheduledTaskRow({ + @@ -294,13 +302,23 @@ export function ResetCredits({ readonly credits: ServerProviderResetCredits; readonly now: number; }) { - const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input); + const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + input, + ); if (credits.availableCount === 0 && status === null) return null; return (
{resetCreditsSummary(credits, now)} {credits.availableCount > 0 ? ( - ) : null} @@ -309,6 +327,7 @@ export function ResetCredits({ open={confirming} onOpenChange={setConfirming} onConfirm={() => void redeem()} + disabled={!canManageProviders} />
); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index d99ce30b475cb53779d4ef7be43cb6ee94356105..9ebe499179b06590712bf9fc820cf43e3cd32b04 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -204,7 +204,7 @@ function SegmentPopover({ ; +} + +function saveSharedSettings() { + renderer!.root.findByType("button").props.onClick(); +} + +async function mountEditor() { + await act(() => { + renderer = create( + + + , + ); + }); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.registry = AtomRegistry.make(); + state.sessions.clear(); + state.sessions.set( + primaryId, + Atom.make(AsyncResult.success(session([AuthSettingsWriteScope]))), + ); + state.sessions.set(remoteId, Atom.make(AsyncResult.initial())); + state.environments = [primaryId, remoteId].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + environment: { capabilities: { threadAutoSettlement: true } }, + settings: DEFAULT_SERVER_SETTINGS, + }, + })); + state.persist.mockReset(); + state.persist.mockResolvedValue(AsyncResult.success(DEFAULT_SERVER_SETTINGS)); + state.toast.mockReset(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + state.registry?.dispose(); + vi.unstubAllGlobals(); +}); + +describe("shared settings writes", () => { + it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => { + await mountEditor(); + saveSharedSettings(); + + expect(state.persist.mock.calls).toEqual([ + [{ environmentId: primaryId, input: { patch } }], + [{ environmentId: remoteId, input: { patch } }], + ]); + expect(state.registry!.get(state.sessions.get(remoteId)!)).toMatchObject({ _tag: "Initial" }); + }); + + it.each([ + ["denied", () => AsyncResult.success(session([]))], + ["denied while refreshing", () => AsyncResult.waiting(AsyncResult.success(session([])))], + [ + "failed", + () => AsyncResult.failure(Cause.fail(new Error("session rejected"))), + ], + ] as const)("skips a remote whose grant is %s", async (_label, result) => { + state.registry!.set(state.sessions.get(remoteId)!, result()); + await mountEditor(); + saveSharedSettings(); + + expect(state.persist).toHaveBeenCalledExactlyOnceWith({ + environmentId: primaryId, + input: { patch }, + }); + }); + + it.each(["disconnected", "unsupported"] as const)( + "skips a %s remote even with a cold grant", + async (condition) => { + const remote = state.environments[1]!; + if (condition === "disconnected") remote.connection.phase = "disconnected"; + else remote.serverConfig.environment.capabilities.threadAutoSettlement = false; + await mountEditor(); + saveSharedSettings(); + + expect(state.persist).toHaveBeenCalledExactlyOnceWith({ + environmentId: primaryId, + input: { patch }, + }); + }, + ); + + it.each(["primary", "hosted"] as const)( + "rechecks a cold remote grant that resolves to denied after the %s handler renders", + async (mode) => { + if (mode === "hosted") { + state.environments = state.environments.filter( + (environment) => environment.environmentId !== primaryId, + ); + } + await mountEditor(); + const previousUpdate = renderer!.root.findByType("button").props.onClick as () => void; + await act(() => { + state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([]))); + }); + previousUpdate(); + + if (mode === "primary") { + expect(state.persist).toHaveBeenCalledExactlyOnceWith({ + environmentId: primaryId, + input: { patch }, + }); + expect(state.toast).not.toHaveBeenCalled(); + } else { + expect(state.persist).not.toHaveBeenCalled(); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "warning", + title: "Setting not saved", + description: "This connection does not have permission to change these settings.", + }); + } + }, + ); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5448873ce400b091988cca3b137f1ba74c368cfa..2e089668ccc992dfef7379c8dc5b32e313f3b565 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -13,6 +13,8 @@ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, + AuthSettingsWriteScope, + requiredScopesForServerSettingsPatch, type EnvironmentId, type ProviderInstanceMutation, ServerSettings, @@ -40,12 +42,16 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; import { toastManager } from "~/components/ui/toast"; import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; +import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -425,6 +431,31 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } +/** Connected sync targets, excluding grants already known to forbid settings writes. */ +function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray { + const { environments } = useEnvironments(); + const writableTargetsAtom = useMemo( + () => + Atom.make((get) => + environments.filter(supportsSharedSettingsSync).flatMap((environment) => { + const result = get(environmentSession.sessionStateAtom(environment.environmentId)); + // A cold grant must not drop a shared edit. The server authorizes the + // write; mismatch suggestions still wait for a confirmed grant. + if (includePending && result._tag === "Initial") { + return [environment.environmentId]; + } + const session = + result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)); + return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope) + ? [environment.environmentId] + : []; + }), + ), + [environments, includePending], + ); + return useAtomValue(writableTargetsAtom); +} + /** * Returns an updater that routes each key to the correct backing store. * @@ -435,16 +466,31 @@ export function usePrimarySettingsAvailable(): boolean { * through client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { + // Mount this session even on pages without a visible permission-gated control. + useEnvironmentScope(environmentId, AuthSettingsWriteScope); const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); const { environments } = useEnvironments(); + const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); - if (Object.keys(serverPatch).length > 0) { + const canWriteServerPatch = + environmentId === null || + requiredScopesForServerSettingsPatch(serverPatch).every((scope) => + readEnvironmentScope(environmentId, scope), + ); + if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: "This connection does not have permission to change these settings.", + }); + } + if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => @@ -467,13 +513,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const sourceSettings = environments.find( (target) => target.environmentId === environmentId, )?.serverConfig?.settings; - const targets = new Set( - environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), - ); + const targets = new Set(sharedSettingsSyncTargetIds); if (environmentId) { targets.add(environmentId); } let wroteToTarget = false; + let permissionDenied = false; for (const targetId of targets) { const target = environments.find((candidate) => candidate.environmentId === targetId); const targetPatch = filterSharedServerPatch( @@ -484,6 +529,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { targetId === environmentId, ); if (Object.keys(targetPatch).length === 0) continue; + const session = appAtomRegistry.get(environmentSession.sessionStateAtom(targetId)); + if ( + session._tag !== "Initial" && + !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) => + readEnvironmentScope(targetId, scope), + ) + ) { + permissionDenied = true; + continue; + } wroteToTarget = true; void persistServerSettings({ environmentId: targetId, @@ -492,7 +547,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } if (!wroteToTarget) { warnUnsaved( - targets.size > 0 ? "Update older servers to save this setting." : undefined, + permissionDenied + ? "This connection does not have permission to change these settings." + : targets.size > 0 + ? "Update older servers to save this setting." + : undefined, ); } } @@ -501,7 +560,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { void persistClientSettingsPatch(clientPatch); } }, - [environmentId, environments, persistServerSettings], + [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], ); return updateSettings; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 58199ddf62892c9ec0748fbd4a668e69a473042a..811d9101cbc7f8d6324956ae5e887f47381fd020 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -25,6 +25,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + resolveChatShortcutCommand, resolveShortcutCommand, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, @@ -886,6 +887,102 @@ describe("cross-command precedence", () => { }); }); +describe("resolveChatShortcutCommand", () => { + it("runs the active environment's script shortcut instead of the primary environment's", () => { + const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); + const active = compile([{ shortcut: modShortcut("t"), command: "script.setup.run" }]); + + assert.strictEqual( + resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, { + platform: "Linux", + }), + "script.setup.run", + ); + assert.isNull( + resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, active, { + platform: "Linux", + }), + ); + }); + + it("resolves shared chords to the active environment's script ID", () => { + const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); + const active = compile([{ shortcut: modShortcut("r"), command: "script.deploy.run" }]); + + assert.strictEqual( + resolveChatShortcutCommand(event({ key: "r", metaKey: true }), primary, active, { + platform: "MacIntel", + }), + "script.deploy.run", + ); + }); + + it("does not run primary scripts before the active environment's bindings arrive", () => { + const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); + + assert.isNull( + resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, [], { + platform: "Linux", + }), + ); + }); + + it("keeps primary app commands ahead of conflicting active script shortcuts", () => { + const primary = compile([{ shortcut: modShortcut("k"), command: "commandPalette.toggle" }]); + const active = compile([{ shortcut: modShortcut("k"), command: "script.setup.run" }]); + + assert.strictEqual( + resolveChatShortcutCommand(event({ key: "k", ctrlKey: true }), primary, active, { + platform: "Linux", + }), + "commandPalette.toggle", + ); + }); + + it("keeps app shortcuts on the primary environment when the active environment remaps them", () => { + const primary = compile([{ shortcut: modShortcut("j"), command: "terminal.toggle" }]); + const active = compile([{ shortcut: modShortcut("t"), command: "terminal.toggle" }]); + + assert.strictEqual( + resolveChatShortcutCommand(event({ key: "j", ctrlKey: true }), primary, active, { + platform: "Linux", + }), + "terminal.toggle", + ); + assert.isNull( + resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, { + platform: "Linux", + }), + ); + }); + + it.each([false, true])( + "preserves active binding precedence with terminalFocus=%s", + (terminalFocus) => { + const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); + const active = compile([ + { shortcut: modShortcut("r"), command: "script.deploy.run" }, + { + shortcut: modShortcut("r"), + command: "terminal.new", + whenAst: whenIdentifier("terminalFocus"), + }, + ]); + const options = { platform: "Linux", context: { terminalFocus } }; + const shortcut = event({ key: "r", ctrlKey: true }); + + assert.strictEqual( + resolveChatShortcutCommand(shortcut, primary, active, options), + terminalFocus ? null : "script.deploy.run", + ); + assert.strictEqual( + resolveChatShortcutCommand(shortcut, active, active, options), + resolveShortcutCommand(shortcut, active, options), + ); + }, + ); +}); + describe("resolveShortcutCommand", () => { it("resolves a custom stop-thread shortcut", () => { const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 7864eabf755b7d8cb686f7576a6768ff82e1028a..92affc39e5250a32ec2cfd3ff7557fc43ff55e15 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -18,6 +18,7 @@ import { type ShortcutModifierStateLike, } from "@t3tools/shared/keybindings"; import { isElectron } from "./env"; +import { projectScriptIdFromCommand } from "./projectScripts"; export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings"; export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings"; @@ -169,6 +170,24 @@ export function resolveShortcutCommand( return null; } +/** App shortcuts use the primary environment; script shortcuts belong to the active project. */ +export function resolveChatShortcutCommand( + event: ShortcutEventLike, + primaryKeybindings: ResolvedKeybindingsConfig, + activeKeybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): KeybindingCommand | null { + const primaryCommand = resolveShortcutCommand(event, primaryKeybindings, options); + if (primaryCommand !== null && projectScriptIdFromCommand(primaryCommand) === null) { + return primaryCommand; + } + + const activeCommand = resolveShortcutCommand(event, activeKeybindings, options); + return activeCommand !== null && projectScriptIdFromCommand(activeCommand) !== null + ? activeCommand + : null; +} + export function formatShortcutKeyLabel(key: string): string { if (key === " ") return "Space"; if (key.length === 1) return key.toUpperCase(); diff --git a/apps/web/src/lib/attachmentUploadQueue.test.ts b/apps/web/src/lib/attachmentUploadQueue.test.ts index ea217616452a2f89dd8933fc9f35c38e6ffb43b1..a92129a23e0e1d7b76f6ff31dbc84480f5e2d373 100644 --- a/apps/web/src/lib/attachmentUploadQueue.test.ts +++ b/apps/web/src/lib/attachmentUploadQueue.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ executeAtomQuery: vi.fn(), removeUpload: Symbol("remove-upload"), runAtomCommand: vi.fn(), + readEnvironmentScope: vi.fn(), readPreparedConnection: vi.fn(), })); @@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({ })); vi.mock("../state/session", () => ({ + readEnvironmentScope: mocks.readEnvironmentScope, readPreparedConnection: mocks.readPreparedConnection, })); @@ -172,6 +174,7 @@ describe("attachmentUploadQueue", () => { mocks.executeAtomQuery.mockReset(); mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} }); mocks.runAtomCommand.mockReset(); + mocks.readEnvironmentScope.mockReset().mockReturnValue(true); mocks.readPreparedConnection.mockReset(); mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" }); mocks.runAtomCommand.mockImplementation( @@ -266,6 +269,114 @@ describe("attachmentUploadQueue", () => { expect(readAttachmentUpload(image.id)).toBeUndefined(); }); + it("keeps an attachment local until its own environment grants write access", async () => { + const image = makeImage("permission-gain"); + mocks.readEnvironmentScope.mockImplementation( + (environmentId) => environmentId === firstEnvironment, + ); + + startAttachmentUpload({ environmentId: secondEnvironment, image }); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + expect(TestXmlHttpRequest.requests).toHaveLength(0); + expect(readAttachmentUpload(image.id)).toBeUndefined(); + expect(image.file).not.toBeNull(); + + mocks.readEnvironmentScope.mockReturnValue(true); + startAttachmentUpload({ environmentId: secondEnvironment, image }); + await Promise.resolve(); + const settled = awaitAttachmentUploads([image.id]); + TestXmlHttpRequest.requests[0]!.complete(); + await settled; + + expect(readAttachmentUpload(image.id)).toMatchObject({ + status: "ready", + environmentId: secondEnvironment, + }); + }); + + it("does not transfer bytes or retry when access is revoked while minting an upload", async () => { + const image = makeImage("revoke-before-bytes"); + let finishMint: (() => void) | undefined; + mocks.runAtomCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + finishMint = () => + resolve({ + _tag: "Success", + value: { + attachmentId: "pending-revoked", + relativeUrl: "/api/attachments/upload/pending-revoked", + expiresAt: 1, + }, + }); + }), + ); + startAttachmentUpload({ environmentId: firstEnvironment, image }); + const settled = awaitAttachmentUploads([image.id]); + mocks.readEnvironmentScope.mockReturnValue(false); + finishMint!(); + await settled; + + expect(TestXmlHttpRequest.requests).toHaveLength(0); + expect(readAttachmentUpload(image.id)).toMatchObject({ + status: "failed", + reason: "This connection cannot upload attachments.", + attachmentId: "pending-revoked", + }); + retryAttachmentUpload({ environmentId: firstEnvironment, image }); + expect(readAttachmentUpload(image.id)?.status).toBe("failed"); + releaseAttachmentUpload(image.id); + expect(readAttachmentUpload(image.id)).toBeUndefined(); + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1); + }); + + it("does not mint queued uploads after access is revoked", async () => { + const images = Array.from({ length: 4 }, (_, index) => makeImage(`queued-${index}`)); + for (const image of images) { + startAttachmentUpload({ environmentId: firstEnvironment, image }); + } + await Promise.resolve(); + expect(TestXmlHttpRequest.requests).toHaveLength(3); + const settled = awaitAttachmentUploads(images.map((image) => image.id)); + mocks.readEnvironmentScope.mockReturnValue(false); + for (const request of TestXmlHttpRequest.requests) { + request.complete(); + } + await settled; + + expect(mocks.runAtomCommand).toHaveBeenCalledTimes(3); + expect(TestXmlHttpRequest.requests).toHaveLength(3); + expect(readAttachmentUpload(images[3]!.id)).toMatchObject({ + status: "failed", + reason: "This connection cannot upload attachments.", + }); + }); + + it("does not delete a persisted upload using another environment's grant", () => { + mocks.readEnvironmentScope.mockImplementation( + (environmentId) => environmentId === firstEnvironment, + ); + releasePersistedAttachmentUpload({ + id: "local-file", + environmentId: secondEnvironment, + attachmentId: "persisted-file", + }); + expect(mocks.runAtomCommand).not.toHaveBeenCalled(); + + mocks.readEnvironmentScope.mockReturnValue(true); + releasePersistedAttachmentUpload({ + id: "local-file", + environmentId: secondEnvironment, + attachmentId: "persisted-file", + }); + expect(mocks.runAtomCommand).toHaveBeenCalledWith( + expect.anything(), + mocks.removeUpload, + { environmentId: secondEnvironment, input: { attachmentId: "persisted-file" } }, + expect.anything(), + ); + }); + it("uploads images immediately and sends attachment references", async () => { const image = { ...makeImage("image-1"), diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 6681579088d40cbe653b4d0f5a8769e1c051e4c2..72cb542c371c7eb754d3cad9c1ca170d80b9ccf7 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -1,4 +1,5 @@ import { + AuthOrchestrationOperateScope, PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, type ChatAttachment, type EnvironmentId, @@ -26,7 +27,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentCatalog } from "../connection/catalog"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; -import { readPreparedConnection } from "../state/session"; +import { readEnvironmentScope, readPreparedConnection } from "../state/session"; import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState"; const MAX_UPLOADS_PER_ENVIRONMENT = 3; @@ -138,6 +139,7 @@ function stampDraftFileUpload(job: UploadJob, attachmentId: string): void { } function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void { + if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; deletePendingAttachmentUpload({ registry: appAtomRegistry, remove: attachmentEnvironment.remove, @@ -179,6 +181,15 @@ function uploadBytes(input: { } async function runUpload(job: UploadJob): Promise { + if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + reason: "This connection cannot upload attachments.", + ...(job.previous ? { previous: job.previous } : {}), + }); + return; + } if (job.persistedAttachmentId) { const verification = await verifyPersistedAttachmentUpload({ registry: appAtomRegistry, @@ -260,6 +271,15 @@ async function runUpload(job: UploadJob): Promise { } let lastStep = -1; + if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { + setUploadState(job.image.id, { + status: "failed", + environmentId: job.environmentId, + reason: "This connection cannot upload attachments.", + ...(job.previous ? { previous: job.previous } : {}), + }); + return; + } const result = await runAttachmentUploadCycle({ registry: appAtomRegistry, createUploadUrl: attachmentEnvironment.createUploadUrl, @@ -272,6 +292,12 @@ async function runUpload(job: UploadJob): Promise { sizeBytes: file.size, }, resolveUploadUrl: (relativeUrl) => { + if ( + job.cancelled || + !readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope) + ) { + return null; + } const connection = readPreparedConnection(job.environmentId); return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; }, @@ -295,7 +321,7 @@ async function runUpload(job: UploadJob): Promise { }, }), onMinted: (attachmentId) => { - if (job.cancelled) { + if (job.cancelled && readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) { return "cancel"; } job.attachmentId = attachmentId; @@ -328,7 +354,9 @@ async function runUpload(job: UploadJob): Promise { result.step === "mint" ? "Upload could not start" : result.step === "resolve-url" - ? "Not connected" + ? readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope) + ? "Not connected" + : "This connection cannot upload attachments." : result.error instanceof Error ? result.error.message : "Upload failed", @@ -388,6 +416,7 @@ export function startAttachmentUpload(input: { /** Draft that owns the file; lets a background completion persist its ids. */ readonly draftTarget?: ComposerThreadTarget; }): void { + if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return; const existingJob = jobsByImageId.get(input.image.id); if (existingJob?.environmentId === input.environmentId) { return; @@ -545,6 +574,7 @@ export function retryAttachmentUpload(input: { readonly image: ComposerImageAttachment | ComposerFileAttachment; readonly draftTarget?: ComposerThreadTarget; }): void { + if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return; const previous = readAttachmentUpload(input.image.id); cancelAttachmentUpload(input.image.id); // A failed state's `attachmentId` is always one this queue minted, so this diff --git a/apps/web/src/lib/resourceTelemetryState.ts b/apps/web/src/lib/resourceTelemetryState.ts index 4b97b3b4671b3295224de3be687e34968411bfe8..f6561ecffcb6cab8a8c33ff689a3fa86ab49e04d 100644 --- a/apps/web/src/lib/resourceTelemetryState.ts +++ b/apps/web/src/lib/resourceTelemetryState.ts @@ -1,3 +1,4 @@ +import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; import type { EnvironmentId, ResourceTelemetryHistoryInput, @@ -9,6 +10,7 @@ import { useCallback } from "react"; import { usePrimaryEnvironment } from "../state/environments"; import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; +import { readEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; export interface ResourceTelemetryState { @@ -39,6 +41,9 @@ export function useResourceTelemetry( if (environmentId === null) { throw new Error("No environment is selected."); } + if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) { + throw new Error("This connection cannot restart the resource monitor."); + } const result = await retryCommand({ environmentId, input: {} }); if (result._tag === "Failure") { throw Cause.squash(result.cause); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index fff3e430091d53ff40dee8daaf3f441644c83ff7..2418fb1132fa111e9b67390358ccaf799629fa4e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,5 @@ import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Outlet, Link, @@ -20,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; +import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning"; import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; @@ -49,7 +49,6 @@ import { ToastProvider, toastManager, } from "../components/ui/toast"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { cn } from "../lib/utils"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; @@ -66,9 +65,7 @@ import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; import { isLocalEnvironmentDisabled } from "../localEnvironment"; -import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; -import { useAtomCommand } from "../state/use-atom-command"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { primaryServerConfigAtom, @@ -496,9 +493,6 @@ function EventRouter({ const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironment = usePrimaryEnvironment(); - const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { - reportFailure: false, - }); const serverConfig = useAtomValue(primaryServerConfigAtom); const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); const serverWelcome = useAtomValue(primaryServerWelcomeAtom); @@ -575,42 +569,14 @@ function EventRouter({ stackedThreadToast({ type: "warning", title: "Invalid keybindings configuration", - description: decision.message, - actionVariant: "outline", - actionProps: { - children: "Open keybindings.json", - onClick: () => { - if (!serverConfig || !primaryEnvironment) { - return; - } - - const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); - if (!editor) { - return; - } - void (async () => { - const result = await openInEditor({ - environmentId: primaryEnvironment.environmentId, - input: { - cwd: serverConfig.keybindingsConfigPath, - editor, - }, - }); - if (result._tag === "Success") { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }), - ); - })(); - }, - }, + description: ( + + ), }), ); }); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index c861245b9997c6cb2515c29d5c57aaff93b110c1..a2c00df4f26f6b5fa465c1f56c15a383ee70f44b 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,6 +1,7 @@ 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 { useMemo } from "react"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -9,6 +10,66 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); +const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); + +/** 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), + ); + const session = Option.getOrNull(AsyncResult.value(result)); + return ( + result._tag !== "Failure" && + session?.authenticated === true && + session.scopes?.includes(scope) === true + ); +} + +/** Subscribe to the grants of every selected environment. */ +export function useEnvironmentsWithScope( + environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, + scope: AuthEnvironmentScope, +): ReadonlySet { + const permitted = useMemo( + () => + Atom.make((get) => { + const ids = new Set(); + for (const { environmentId } of environments) { + const result = get(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(result)); + if ( + result._tag !== "Failure" && + session?.authenticated === true && + session.scopes?.includes(scope) + ) { + ids.add(environmentId); + } + } + return ids; + }), + [environments, scope], + ); + return useAtomValue(permitted); +} + +export function readEnvironmentScope( + environmentId: EnvironmentId, + scope: AuthEnvironmentScope, +): boolean { + const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(result)); + return ( + result._tag !== "Failure" && + session?.authenticated === true && + session.scopes?.includes(scope) === true + ); +} + const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( Atom.withLabel("web-prepared-connection:empty"), ); diff --git a/apps/web/src/state/use-orchestration-command.test.ts b/apps/web/src/state/use-orchestration-command.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44941063a8dc8b526f95973dcadf183fa7e2d9a9 --- /dev/null +++ b/apps/web/src/state/use-orchestration-command.test.ts @@ -0,0 +1,85 @@ +import type { AtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { assert, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + allowed: new Set(), + readScope: vi.fn(), + run: vi.fn(), +})); + +vi.mock("react", () => ({ useCallback: (callback: unknown) => callback })); +vi.mock("./session", () => ({ readEnvironmentScope: state.readScope })); +vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run })); + +import { useOrchestrationCommand } from "./use-orchestration-command"; + +const first = EnvironmentId.make("primary"); +const second = EnvironmentId.make("secondary"); +type Target = { environmentId: EnvironmentId; input: { threadId: string } }; +const command: AtomCommand = { label: "thread mutation", run: state.run }; +const target = (environmentId: EnvironmentId): Target => ({ + environmentId, + input: { threadId: "thread-1" }, +}); + +beforeEach(() => { + state.allowed.clear(); + state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt")); + state.readScope + .mockReset() + .mockImplementation( + (environmentId, scope) => + scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId), + ); +}); + +it("checks the command's environment instead of borrowing the primary grant", async () => { + state.allowed.add(first); + const mutate = useOrchestrationCommand(command, { reportFailure: false }); + const result = await mutate(target(second)); + assert(result._tag === "Failure"); + const error = Cause.squash(result.cause); + expect(error).toBeInstanceOf(EnvironmentAuthorizationError); + expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope }); + expect(state.run).not.toHaveBeenCalled(); + + expect(await mutate(target(first))).toMatchObject({ _tag: "Success", value: "receipt" }); + expect(state.run).toHaveBeenCalledWith(target(first)); +}); + +it("a retained callback follows both revocation and a new grant without remounting", async () => { + state.allowed.add(second); + const mutate = useOrchestrationCommand(command); + state.allowed.delete(second); + expect((await mutate(target(second)))._tag).toBe("Failure"); + expect(state.run).not.toHaveBeenCalled(); + + state.allowed.add(second); + expect((await mutate(target(second)))._tag).toBe("Success"); + expect(state.run).toHaveBeenCalledTimes(1); +}); + +it("does not dispatch a later mutation after access changes while awaiting a receipt", async () => { + state.allowed.add(second); + let finish: (() => void) | undefined; + state.run.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(AsyncResult.success("first receipt")); + }), + ); + const mutate = useOrchestrationCommand(command); + const pending = mutate(target(second)); + state.allowed.delete(second); + finish!(); + expect((await pending)._tag).toBe("Success"); + expect((await mutate(target(second)))._tag).toBe("Failure"); + expect(state.run).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..71bee0a35b5d23965a7f99a2dcb1b1621244b23d --- /dev/null +++ b/apps/web/src/state/use-orchestration-command.ts @@ -0,0 +1,43 @@ +import type { + AtomCommand, + AtomCommandOptions, + AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + EnvironmentAuthorizationError, + type EnvironmentId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { useCallback } from "react"; + +import { readEnvironmentScope } from "./session"; +import { useAtomCommand } from "./use-atom-command"; + +export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = + "This connection cannot change threads or projects."; + +/** Recheck the target grant for every step of a thread or project mutation. */ +export function useOrchestrationCommand( + command: AtomCommand, + options?: string | AtomCommandOptions, +): (value: W) => Promise> { + const run = useAtomCommand(command, options); + return useCallback( + async (value: W): Promise> => { + if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationOperateScope, + message: ORCHESTRATION_OPERATE_DENIED_MESSAGE, + }), + ), + ); + } + return run(value); + }, + [run], + ); +} diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index 3acea00472e412126d491362f29d56c7c441557a..58c5ab4ef5c1d21322690131d5a20a740e8b5e35 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -58,6 +58,11 @@ extra authority: [every RPC declares a required scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC group's `RpcScopeAuthorization` middleware checks it before any handler runs. +Self-update must work across authorization protocol changes. New servers advertise +`auth.serverUpdateScope`; only an older server that omits it uses +`orchestration:operate` for updates. An unchanged grant on an upgraded server must +still include `environment:maintain`. + Desktop restarts forget the previous local bearer token, so its reusable bootstrap grant replaces earlier sessions for the same subject and method. Revocation and insertion share a [database diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 4f6eb711526a0cd87031750db3ae8e88180553a6..04a4a4c0f01e2c94dbb3b47660c060a07a0966e0 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -277,6 +277,16 @@ in web or desktop, use **Add Environment** with the fresh link or code; pairing the same environment replaces its saved grant. Reconnecting alone does not change permissions. +Grouping checkouts does not combine their permissions. Shared project settings +require `orchestration:operate` on every member environment; actions on one +checkout use that checkout's permissions. + +Settings changes, provider management, and environment maintenance can be granted +separately from access administration. New standard pairings include these +permissions. Existing clients keep their original grants after an update; to +receive newly separated permissions, pair the client again with the scopes it +needs. Reconnecting or refreshing a session does not expand its grant. + To remove an environment from T3 Connect, open your account menu's **T3 Connect** page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This revokes its cloud access and frees its host space even when the environment is diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f8c47dfccdf19445ff9b3c48d0c42633677eb961..50fe249d9ba51b6ec3dba6f438ee0486c38af610 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; import requireSuppressionReason from "./rules/require-suppression-reason.ts"; @@ -27,6 +28,7 @@ export default definePlugin({ "no-native-title-tooltip": noNativeTitleTooltip, "no-raw-mcp-registration": noRawMcpRegistration, "no-test-in-loop": noTestInLoop, + "no-rpc-permission-bypass": noRpcPermissionBypass, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, "require-suppression-reason": requireSuppressionReason, diff --git a/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.test.ts b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dbf8778a7acc5acdaf1be5c4476f9a88ecdd198 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.test.ts @@ -0,0 +1,69 @@ +import { createOxlintRuleHarness } from "../test/utils.ts"; +const state = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { + filename: "fixture.ts", +}); +state.invalid("blocks direct session calls", "session.client[tag](input);"); +state.invalid("blocks extracting the raw client", 'const raw = session["client"];'); +state.invalid( + "blocks replacing the permission guard", + 'import { RpcPermissionGuard as Guard } from "../rpc/client.ts";', +); +state.invalid( + "blocks namespace guard access", + 'import * as Rpc from "../rpc/client.ts"; const guard = Rpc.RpcPermissionGuard;', +); +state.invalid("blocks raw protocol imports", 'import { makeClient } from "../rpc/protocol.ts";'); +state.valid( + "allows typed requests", + 'import { request } from "../rpc/client.ts"; request(method, input);', +); +const rpc = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { + filename: "fixture.ts", + ruleOptions: [{ allowRawClientAccess: true }], +}); +rpc.valid("allows the transport boundary", "session.client[tag](input);"); + +const app = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { + filename: "fixture.ts", +}); +app.invalid( + "blocks guard imports through the barrel", + 'import { RpcPermissionGuard as Guard } from "@t3tools/client-runtime/rpc";', +); +app.invalid( + "blocks explicit index imports", + 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc/index.ts";', +); +app.invalid( + "blocks raw session access in subscription callbacks", + "subscribeDynamic(tag, session => session.client[method](input));", +); +app.invalid( + "blocks raw client destructuring", + "const { client: raw } = session; raw[method](input);", +); +app.valid( + "allows public typed RPC helpers", + 'import { request, runStream } from "@t3tools/client-runtime/rpc";', +); + +rpc.invalid( + "raw client allowance does not permit installing the guard", + 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc";', +); +const boundary = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", { + filename: "fixture.ts", + ruleOptions: [{ allowGuardInstallation: true }], +}); +boundary.valid( + "permits guard installation when configured", + 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc"; RpcPermissionGuard.of({ authorize });', +); +boundary.invalid( + "guard installation allowance does not permit raw clients", + "session.client[tag](input);", +); +boundary.invalid( + "guard installation allowance does not permit raw protocol imports", + 'import { makeWsRpcProtocolClient } from "../rpc/protocol.ts";', +); diff --git a/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts new file mode 100644 index 0000000000000000000000000000000000000000..e968507c25c378975504c97314d24400ec602d85 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; +import * as Option from "effect/Option"; +import { getPropertyName } from "../utils.ts"; + +const readOptions = (options: ReadonlyArray) => { + const [first] = options; + return { + allowRawClientAccess: + typeof first === "object" && + first !== null && + "allowRawClientAccess" in first && + first.allowRawClientAccess === true, + allowGuardInstallation: + typeof first === "object" && + first !== null && + "allowGuardInstallation" in first && + first.allowGuardInstallation === true, + }; +}; + +/** A guardrail for ordinary edits, not a security boundary against deliberate casts or aliases. */ +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Keep raw RPC access and permission guard installation inside their shared boundaries.", + }, + schema: [ + { + type: "object", + properties: { + allowRawClientAccess: { + type: "boolean", + description: "Permit .client access for non-RPC clients in the configured files.", + }, + allowGuardInstallation: { + type: "boolean", + description: "Permit installing RpcPermissionGuard in a reviewed command boundary.", + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowRawClientAccess: false, allowGuardInstallation: false }], + }, + create(context) { + const { allowRawClientAccess, allowGuardInstallation } = readOptions(context.options); + const report = (node: NonNullable[0]["node"]>) => + context.report({ + node, + message: + "Use permission-aware environment commands. Raw RPC access and RpcPermissionGuard belong in the shared RPC/command boundary.", + }); + return { + ImportDeclaration(node) { + if (typeof node.source.value !== "string") return; + const source = node.source.value; + if (/(?:^|\/)rpc\/protocol(?:\.ts)?$/.test(source) && node.importKind !== "type") + report(node); + if ( + !allowGuardInstallation && + /(?:^|\/)rpc(?:\/(?:client|index)(?:\.ts)?)?$/.test(source) + ) { + for (const specifier of node.specifiers) { + if ( + specifier.type === "ImportSpecifier" && + Option.getOrNull(getPropertyName(specifier.imported)) === "RpcPermissionGuard" + ) + report(specifier); + } + } + }, + VariableDeclarator(node) { + if (allowRawClientAccess || node.id.type !== "ObjectPattern") return; + for (const property of node.id.properties) { + if ( + property.type === "Property" && + Option.getOrNull(getPropertyName(property.key)) === "client" + ) + report(property); + } + }, + MemberExpression(node) { + const property = Option.getOrNull(getPropertyName(node.property)); + if (!allowGuardInstallation && property === "RpcPermissionGuard") report(node); + if (!allowRawClientAccess && property === "client") report(node); + }, + }; + }, +}); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 89ecebe535b3f76578af4742ec380725adf82573..d8ffc24c458972ccd601db1f662cfa5d0fb2621a 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -1,5 +1,8 @@ import { EnvironmentAuthorizationError, + clientRpcRequiredScopes, + type EnvironmentId, + type ClientGuardedRpcTag, ORCHESTRATION_V2_WS_METHODS, WS_METHODS, } from "@t3tools/contracts"; @@ -146,7 +149,40 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer }, ); -export const request = Effect.fn("EnvironmentRpc.request")(function* < +/** Installed by the shared command boundary. A missing boundary denies protected writes. */ +export class RpcPermissionGuard extends Context.Reference<{ + readonly authorize: ( + environmentId: EnvironmentId, + method: string, + input: unknown, + ) => Effect.Effect; +}>("@t3tools/client-runtime/rpc/RpcPermissionGuard", { + defaultValue: () => ({ + authorize: (_environmentId, method, input) => { + const scope = clientRpcRequiredScopes(method, input)[0]; + return scope === undefined + ? Effect.void + : Effect.fail( + new EnvironmentAuthorizationError({ + requiredScope: scope, + message: `This connection requires ${scope}.`, + }), + ); + }, + }), +}) {} + +const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( + method: string, + input: unknown, +) { + if (clientRpcRequiredScopes(method, input).length === 0) return; + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + const guard = yield* RpcPermissionGuard; + yield* guard.authorize(supervisor.target.environmentId, method, input); +}); + +export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, >(tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -155,6 +191,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < "rpc.method": tag, }); const session = yield* currentSession(); + yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -166,16 +203,19 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); -export function runStream( +function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, ): Stream.Stream< EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure | EnvironmentRpcUnavailableError, + | EnvironmentRpcStreamFailure + | EnvironmentRpcUnavailableError + | EnvironmentAuthorizationError, EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( - currentSession().pipe( + authorizeRequest(tag, input).pipe( + Effect.andThen(currentSession()), Effect.map((session) => { const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -393,3 +433,16 @@ export function subscribe( > { return subscribeDynamic(tag, () => Effect.succeed(input), options); } + +/** Protected writes must go through the permission-aware command layer. */ +export const request = >( + tag: TTag, + input: EnvironmentRpcInput, +) => requestGuarded(tag, input); + +export const runStream = < + TTag extends Exclude, +>( + tag: TTag, + input: EnvironmentRpcInput, +) => runStreamGuarded(tag, input); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index d5b1a6858f41fc34196cf536969b304a70f20edb..e0e269bdfa8ad31d4d273da5809a9f3e91caa63a 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; -export * from "./protocol.ts"; +export type { WsRpcProtocolClient } from "./protocol.ts"; export { type RpcSession } from "./session.ts"; diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3142d047f4de46a9ba0f75d6a464b2a437a26ad --- /dev/null +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import { + AuthOrchestrationOperateScope, + EnvironmentId, + ScheduledTaskId, + WS_METHODS, + type AuthSessionState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { createCommandPermissions } from "./commandPermissions.ts"; +import { createEnvironmentRpcCommand } from "./runtime.ts"; + +vi.mock("./session.ts", () => ({ + createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }), +})); +const sessions = Atom.family((_id: EnvironmentId) => + Atom.make>(AsyncResult.initial()), +); +const env = EnvironmentId.make("target"); +const other = EnvironmentId.make("other"); +const grant = (allowed: boolean): AuthSessionState => ({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "test", + }, + scopes: allowed ? [AuthOrchestrationOperateScope] : [], +}); +const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry, { + run: (_id: EnvironmentId, effect: Effect.Effect) => effect, + } as unknown as EnvironmentRegistry["Service"]), +); +const permissions = createCommandPermissions(runtime, WS_METHODS.scheduledTasksDelete); +const setup = Effect.gen(function* () { + const registry = AtomRegistry.make(); + registry.mount(sessions(env)); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + return registry; +}); + +describe("command permissions", () => { + it.effect("uses the target grant for both availability and execution", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(permissions.permissionAtom(env))).toBe(true); + expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env)); + yield* permissions.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(permissions.permissionAtom(other))).toBe(false); + const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip); + expect(denied._tag).toBe("EnvironmentAuthorizationError"); + }), + ), + ); + it.effect("waits for an initial grant", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const fiber = yield* permissions.authorize(registry, env).pipe(Effect.forkChild); + yield* Effect.yieldNow; + registry.set(sessions(env), AsyncResult.success(grant(true))); + yield* Fiber.join(fiber); + }), + ), + ); + it.effect("bounds a session that never loads", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const fiber = yield* permissions + .authorize(registry, env) + .pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust("6 seconds"); + expect((yield* Fiber.join(fiber))._tag).toBe("EnvironmentAuthorizationError"); + }), + ), + ); + it.effect( + "denies failed and unauthenticated sessions, but accepts a cached refreshing grant", + () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + registry.set(sessions(env), AsyncResult.failure(Cause.fail("offline"))); + expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ ...grant(true), authenticated: false }), + ); + expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set(sessions(env), AsyncResult.waiting(AsyncResult.success(grant(true)))); + yield* permissions.authorize(registry, env); + }), + ), + ); + it("rechecks permission after waiting in a serial command lane", async () => { + const registry = AtomRegistry.make(); + const unmount = registry.mount(sessions(env)); + registry.set(sessions(env), AsyncResult.success(grant(true))); + let release!: () => void; + let started!: () => void; + const entered = new Promise((resolve) => { + started = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + let calls = 0; + const command = createEnvironmentRpcCommand(runtime, { + label: "test.delete", + tag: WS_METHODS.scheduledTasksDelete, + concurrency: { mode: "serial", key: () => "target" }, + execute: () => + Effect.promise(async () => { + calls++; + started(); + await gate; + return { id: ScheduledTaskId.make("task") }; + }), + }); + const target = { environmentId: env, input: { id: ScheduledTaskId.make("task") } }; + try { + const first = command.run(registry, target); + await entered; + const second = command.run(registry, target); + registry.set(sessions(env), AsyncResult.success(grant(false))); + release(); + expect((await first)._tag).toBe("Success"); + expect((await second)._tag).toBe("Failure"); + expect(calls).toBe(1); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6dccd2908174ad72c5690e2db080e7041ca55ba --- /dev/null +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -0,0 +1,97 @@ +import { + clientRpcRequiredScopes, + EnvironmentAuthorizationError, + type EnvironmentId, + type AuthSessionState, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentSessionAtoms } from "./session.ts"; + +function grants(session: AuthSessionState, scope: AuthEnvironmentScope) { + return session.authenticated && session.scopes?.includes(scope) === true; +} + +/** UI availability and dispatch use the same target session and method policy. */ +function makeCommandPermissions( + runtime: Atom.AtomRuntime, + method: string, +) { + const sessions = createEnvironmentSessionAtoms(runtime); + const requiredScopes = (input?: unknown) => clientRpcRequiredScopes(method, input); + const atomsByEnvironment = Atom.family((environmentId: EnvironmentId | null) => + Atom.family((scopeKey: string) => + Atom.make((get) => { + if (environmentId === null) return false; + const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[]; + if (scopes.length === 0) return true; + const result = get(sessions.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(result)); + return ( + result._tag !== "Failure" && + session !== null && + scopes.every((scope) => grants(session, scope)) + ); + }), + ), + ); + const permissionAtom = (environmentId: EnvironmentId | null, input?: unknown) => + atomsByEnvironment(environmentId)(requiredScopes(input).join(",")); + const authorize = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + input?: unknown, + ) => + Effect.suspend(() => { + const scopes = requiredScopes(input); + if (scopes.length === 0) return Effect.void; + const atom = sessions.sessionStateAtom(environmentId); + return Effect.scoped( + Effect.gen(function* () { + yield* AtomRegistry.mount(registry, atom); + const session = yield* AtomRegistry.getResult(registry, atom, { + suspendOnWaiting: false, + }).pipe( + Effect.timeoutOption(6_000), + Effect.catch(() => Effect.succeed(Option.none())), + ); + const missing = scopes.find( + (scope) => Option.isNone(session) || !grants(session.value, scope), + ); + if (missing !== undefined) + return yield* Effect.fail( + new EnvironmentAuthorizationError({ + requiredScope: missing, + message: `This connection requires ${missing}.`, + }), + ); + }), + ); + }); + return { requiredScopes, permissionAtom, authorize }; +} + +const permissionsByRuntime = new WeakMap< + object, + Map> +>(); + +/** Reuse availability atoms and dispatch guards across commands on the same runtime. */ +export function createCommandPermissions( + runtime: Atom.AtomRuntime, + method: string, +) { + let permissions = permissionsByRuntime.get(runtime); + if (permissions === undefined) { + permissions = new Map(); + permissionsByRuntime.set(runtime, permissions); + } + const existing = permissions.get(method); + if (existing !== undefined) return existing; + const created = makeCommandPermissions(runtime, method); + permissions.set(method, created); + return created; +} diff --git a/packages/client-runtime/src/state/environmentStreams.ts b/packages/client-runtime/src/state/environmentStreams.ts new file mode 100644 index 0000000000000000000000000000000000000000..6234973238e782c8cd27623f6216d4e0d04ea249 --- /dev/null +++ b/packages/client-runtime/src/state/environmentStreams.ts @@ -0,0 +1,35 @@ +import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import type * as EnvironmentSupervisor from "../connection/supervisor.ts"; + +export function runStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream< + A, + E | EnvironmentRegistry.EnvironmentNotRegisteredError, + EnvironmentRegistry.EnvironmentRegistry | Exclude +> { + return Stream.unwrap( + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.map((registry) => registry.runStream(environmentId, stream)), + ), + ); +} + +export function followStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream< + A, + E, + EnvironmentRegistry.EnvironmentRegistry | Exclude +> { + return Stream.unwrap( + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.map((registry) => registry.followStream(environmentId, stream)), + ), + ); +} diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index ce46fb3149477c0b6b58bfb815a776c5ce40884d..f8781837c36191e29cd14cca7d5a8f87c46477e5 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -24,7 +24,7 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import * as ConnectionProfileStore from "../connection/profileStore.ts"; import type { RpcSession } from "../rpc/session.ts"; import { - request, + requestGuarded as request, EnvironmentRpcUnavailableError, type EnvironmentRpcInput, type EnvironmentUnaryRpcTag, diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 64f91d5681aa6baf857e2db569cf05397435fcfb..0d416362d46b17c2127a61acd5cd96724d5c9299 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -1,4 +1,11 @@ -import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import { createCommandPermissions } from "./commandPermissions.ts"; +import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; +export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; +import { + type ClientGuardedRpcTag, + EnvironmentId, + type EnvironmentId as EnvironmentIdType, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -19,6 +26,8 @@ import { type EnvironmentUnaryRpcTag, EnvironmentRpcUnavailableError, request, + requestGuarded, + RpcPermissionGuard, subscribe, } from "../rpc/client.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; @@ -459,36 +468,6 @@ function runInEnvironment( ); } -export function runStreamInEnvironment( - environmentId: EnvironmentIdType, - stream: Stream.Stream, -): Stream.Stream< - A, - E | EnvironmentRegistry.EnvironmentNotRegisteredError, - EnvironmentRegistry.EnvironmentRegistry | Exclude -> { - return Stream.unwrap( - EnvironmentRegistry.EnvironmentRegistry.pipe( - Effect.map((registry) => registry.runStream(environmentId, stream)), - ), - ); -} - -export function followStreamInEnvironment( - environmentId: EnvironmentIdType, - stream: Stream.Stream, -): Stream.Stream< - A, - E, - EnvironmentRegistry.EnvironmentRegistry | Exclude -> { - return Stream.unwrap( - EnvironmentRegistry.EnvironmentRegistry.pipe( - Effect.map((registry) => registry.followStream(environmentId, stream)), - ), - ); -} - /** * Refreshes a query when `signal` changes, but only while something reads it. * `Atom.makeRefreshOnSignal` subscribes to the signal, so a query that outlives its view on an @@ -660,7 +639,11 @@ export function createEnvironmentCommand( }); } -export function createEnvironmentRpcQueryAtomFamily( +export function createEnvironmentRpcQueryAtomFamily< + R, + ER, + TTag extends Exclude, +>( runtime: Atom.AtomRuntime, options: { readonly label: string; @@ -767,7 +750,8 @@ export function createEnvironmentRpcCommand Effect.Effect; }, ) { - return createEnvironmentCommand(runtime, { + const permissions = createCommandPermissions(runtime, options.tag); + const command = createEnvironmentCommand(runtime, { label: options.label, ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }), ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }), @@ -776,10 +760,22 @@ export function createEnvironmentRpcCommand options.execute?.(input) ?? requestGuarded(options.tag, input)), + Effect.provideService(RpcPermissionGuard, { + authorize: (id, method, payload) => + createCommandPermissions(runtime, method).authorize(registry, id, payload), + }), Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void), Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void), ); }, }); + return { + ...command, + requiredScopes: permissions.requiredScopes, + permissionAtom: permissions.permissionAtom, + }; } diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 822efa9a1d257a6cea287d5c3e020354c027e685..63c23d735fa9fb7527a678f2cb0a348eda6a38cb 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,9 +1,10 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import type { HttpClient } from "effect/http"; +import { HttpClient } from "effect/http"; import { AsyncResult, Atom } from "effect/reactivity"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; @@ -14,7 +15,12 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; -import { followStreamInEnvironment } from "./runtime.ts"; +import { followStreamInEnvironment } from "./environmentStreams.ts"; + +/** @public Required to name the error in consumers' inferred session results. */ +export class SessionHttpClientUnavailable extends Data.TaggedError( + "SessionHttpClientUnavailable", +) {} function initialConfigOption( initialConfig: Effect.Effect, @@ -61,8 +67,8 @@ export const fetchEnvironmentSessionState = Effect.fn( }); }); -export function createEnvironmentSessionAtoms( - runtime: Atom.AtomRuntime, +function makeEnvironmentSessionAtoms( + runtime: Atom.AtomRuntime, ) { const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( @@ -137,7 +143,13 @@ export function createEnvironmentSessionAtoms( const remoteAuthorization = yield* Effect.serviceOption( RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, ); - return yield* fetchEnvironmentSessionState({ prepared, signer, remoteAuthorization }); + const client = yield* Effect.serviceOption(HttpClient.HttpClient); + if (Option.isNone(client)) return yield* new SessionHttpClientUnavailable(); + return yield* fetchEnvironmentSessionState({ + prepared, + signer, + remoteAuthorization, + }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); }); }) .pipe( @@ -163,3 +175,16 @@ export function createEnvironmentSessionAtoms( sessionStateValueAtom, }; } + +const sessionAtomsByRuntime = new WeakMap>(); + +/** Commands and UI share one session fetch and the same reconnect invalidation. */ +export function createEnvironmentSessionAtoms( + runtime: Atom.AtomRuntime, +): ReturnType> { + const existing = sessionAtomsByRuntime.get(runtime); + if (existing) return existing as ReturnType>; + const atoms = makeEnvironmentSessionAtoms(runtime); + sessionAtomsByRuntime.set(runtime, atoms); + return atoms; +} diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 9907c99cfc21e2d3c2e5a43dfd053e321c10d7b0..6c1fbcd4a72089cade069d253c306549e0e6d507 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -75,6 +75,8 @@ function harness(ids = ["a"]) { get(environmentId).query, refreshUsageRates: { label: "test:rates", + requiredScopes: () => [], + permissionAtom: () => Atom.make(true), run: ( _registry: AtomRegistry.AtomRegistry, { environmentId }: { environmentId: EnvironmentId }, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 886cd9add685471d1abad80fb280c0b7f96106ad..f811a520c238f752e96b9793cc57bbbe60eb562a 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -80,6 +80,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; export const AuthOrchestrationReadScope = "orchestration:read" as const; export const AuthOrchestrationOperateScope = "orchestration:operate" as const; +export const AuthSettingsWriteScope = "settings:write" as const; +export const AuthProvidersManageScope = "providers:manage" as const; +export const AuthEnvironmentMaintainScope = "environment:maintain" as const; export const AuthTerminalOperateScope = "terminal:operate" as const; export const AuthReviewWriteScope = "review:write" as const; export const AuthAccessReadScope = "access:read" as const; @@ -89,6 +92,9 @@ export const AuthRelayWriteScope = "relay:write" as const; export const AuthEnvironmentScope = Schema.Literals([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, @@ -103,6 +109,9 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type; export const AuthStandardClientScopes = [ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthRelayReadScope, @@ -145,6 +154,8 @@ export const ServerAuthDescriptor = Schema.Struct({ bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod), sessionMethods: Schema.Array(ServerAuthSessionMethod), sessionCookieName: TrimmedNonEmptyString, + /** Older servers omit this and authorize self-updates with orchestration:operate. */ + serverUpdateScope: Schema.optionalKey(Schema.Literal(AuthEnvironmentMaintainScope)), }); export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4f535d792daf0f20c385b36d79ecfaf5fc2548f --- /dev/null +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -0,0 +1,21 @@ +import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts"; +import { WS_METHODS } from "./rpc.ts"; + +/** Incremental client enforcement; the server still authorizes every request. */ +export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, +} as const; +export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; + +export function clientRpcRequiredScopes( + method: string, + _input: unknown, +): readonly AuthEnvironmentScope[] { + return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method) + ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]] + : []; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 277a341f3a50523d3749962ea1248294d59410d7..3a67ab2ccee52f66802b3166f8a1b1b39c57bd8d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -62,3 +62,4 @@ export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; +export * from "./clientRpcPermissions.ts"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7984ca20b57aba97387a4b785ab62eb283bf4108..942fbb915ffe3ff38e50d6c1fd087f070a726d1c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,9 @@ import { SshDeviceHostConfigs } from "./device.ts"; +import { + AuthSettingsWriteScope, + AuthProvidersManageScope, + type AuthEnvironmentScope, +} from "./auth.ts"; import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; @@ -1790,6 +1795,26 @@ export const ServerSettingsPatch = Schema.Struct({ }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; +/** A mixed settings patch must be authorized for every configuration domain it changes. */ +export function requiredScopesForServerSettingsPatch( + patch: ServerSettingsPatch, +): ReadonlyArray { + let changesProviders = false; + let changesSettings = false; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { + changesProviders = true; + } else { + changesSettings = true; + } + } + return [ + ...(changesSettings || !changesProviders ? [AuthSettingsWriteScope] : []), + ...(changesProviders ? [AuthProvidersManageScope] : []), + ]; +} + export const ClientSettingsPatch = Schema.Struct({ notificationMode: Schema.optionalKey(NotificationMode), inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diff --git a/vite.config.ts b/vite.config.ts index ca3ed94432ccafb1899b208abbf1708f2301f45c..b3cd99cafba7ef56eec9c5f7533687a291d1f973 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -169,12 +169,53 @@ export default defineConfig({ "t3code/no-native-title-tooltip": "error", "t3code/no-raw-mcp-registration": "error", "t3code/no-test-in-loop": "error", + "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }], "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", "t3code/prefer-catch-tags": "error", "t3code/require-suppression-reason": "error", }, overrides: [ + { + files: ["packages/client-runtime/src/state/**", "apps/{web,mobile,desktop}/src/**"], + rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: false }] }, + }, + { + // Only shared command boundaries install the session-backed permission guard. + files: [ + "packages/client-runtime/src/state/runtime.ts", + "packages/client-runtime/src/state/vcsAction.ts", + ], + rules: { + "t3code/no-rpc-permission-bypass": [ + "error", + { allowGuardInstallation: true, allowRawClientAccess: false }, + ], + }, + }, + { + // These clients are session metadata, device streams, and an Expo update adapter. + files: [ + "apps/web/src/components/settings/ConnectionsSettings.tsx", + "apps/mobile/src/features/updates/app-updates.ts", + "apps/web/src/components/device/DevicePhoneViewport.tsx", + "apps/web/src/components/device/DeviceDuoViewport.tsx", + ], + rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }] }, + }, + { + // Incompatible hosts cannot open a normal session; their updater uses a dedicated socket. + files: ["packages/client-runtime/src/connection/outdatedHostUpdate.ts"], + rules: { "t3code/no-rpc-permission-bypass": "off" }, + }, + { + // RPC implementation and transport test fixtures need the raw client. + files: [ + "packages/client-runtime/src/rpc/**", + "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", + ], + rules: { "t3code/no-rpc-permission-bypass": "off" }, + }, { // The one place that reads the host platform to seed the injected references. files: ["packages/shared/src/hostProcess.ts"],