From 89b0d916f8e1243a32ea665ced39ab9a79215add Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:53:52 -0700 Subject: [PATCH 01/39] feat(auth): separate environment administration scopes --- .../features/settings/SettingsRouteScreen.tsx | 56 +++- .../src/features/usage/UsageLimitsSection.tsx | 34 ++- apps/mobile/src/state/session.ts | 38 ++- apps/server/src/auth/EnvironmentAuth.test.ts | 21 +- .../src/auth/EnvironmentAuthAdmin.test.ts | 23 +- .../server/src/auth/PairingGrantStore.test.ts | 20 +- apps/server/src/auth/RpcAuthorization.test.ts | 3 +- apps/server/src/auth/RpcAuthorization.ts | 43 +-- apps/server/src/auth/SessionStore.test.ts | 10 +- apps/server/src/auth/http.ts | 6 + apps/server/src/bin.test.ts | 23 +- apps/server/src/server.test.ts | 137 +++++++-- apps/server/src/ws.ts | 15 +- apps/web/src/components/ChatView.tsx | 69 ++++- .../src/components/ProjectScriptsControl.tsx | 17 +- .../ProviderUpdateEnvironmentRows.test.tsx | 14 + .../ProviderUpdateEnvironmentRows.tsx | 18 +- .../ProviderUpdatePrimaryNotification.tsx | 122 +++++--- .../components/ServerUpdateAction.test.tsx | 16 + .../web/src/components/ServerUpdateAction.tsx | 20 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- .../cloud/ConnectOnboardingDialog.tsx | 80 +++-- .../src/components/projectScriptEditor.tsx | 31 +- .../settings/ConnectionsSettings.tsx | 285 ++++++++++++------ .../settings/DiagnosticsSettings.tsx | 41 ++- .../settings/EnvironmentIconPicker.test.ts | 4 +- .../settings/EnvironmentIconPicker.tsx | 41 +-- .../settings/KeybindingsSettings.tsx | 28 +- .../settings/ProjectSettingsPanel.tsx | 59 +++- .../settings/ProviderInstanceCard.tsx | 8 +- .../settings/ProviderModelsSection.tsx | 19 +- ...ProviderSettingsPanel.environment.test.tsx | 21 +- .../ProviderSettingsPanel.logic.test.ts | 35 ++- .../settings/ProviderSettingsPanel.logic.ts | 60 +--- .../settings/ProviderSettingsPanel.tsx | 172 ++++------- .../settings/ResourceTelemetryDiagnostics.tsx | 72 ++++- .../components/settings/settingsLayout.tsx | 14 +- .../src/components/settings/settingsSearch.ts | 2 +- .../useAvailableSettingsSearchItems.ts | 18 +- apps/web/src/components/usage/UsageLimits.tsx | 17 +- apps/web/src/hooks/useSettings.ts | 61 +++- apps/web/src/lib/resourceTelemetryState.ts | 10 +- apps/web/src/state/session.ts | 35 ++- docs/user/remote-access.md | 6 + packages/contracts/src/auth.ts | 9 + packages/contracts/src/settings.ts | 25 ++ 46 files changed, 1256 insertions(+), 604 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 3bd25a20c8da..49cbebfcb080 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -35,9 +35,11 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; +import { environmentSession, readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironments } from "../../state/environments"; import { + AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, @@ -555,9 +557,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD /** * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first eligible sync target provides the - * reference value. Edits fan out to every eligible target, and a mismatch row - * lets the user push the reference out. + * has no primary environment, so the first writable sync target provides the + * reference value. Edits fan out to writable targets, and a mismatch row lets + * the user push the reference out. Read-only connections still show settings. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -566,8 +568,30 @@ function AutoSettleSettingsRows() { reportFailure: true, }); - const syncTargets = environments.filter(supportsSharedSettingsSync); - const reference = syncTargets[0] ?? null; + const writableEnvironmentIdsAtom = useMemo( + () => + Atom.make( + (get) => + new Set( + environments.filter(supportsSharedSettingsSync).flatMap((environment) => { + const result = get(environmentSession.sessionStateAtom(environment.environmentId)); + const session = result._tag === "Success" ? result.value : null; + return session?.authenticated === true && + session.scopes?.includes(AuthSettingsWriteScope) + ? [environment.environmentId] + : []; + }), + ), + ), + [environments], + ); + const writableEnvironmentIds = useAtomValue(writableEnvironmentIdsAtom); + const availableTargets = environments.filter(supportsSharedSettingsSync); + const syncTargets = availableTargets.filter((environment) => + writableEnvironmentIds.has(environment.environmentId), + ); + const canWriteSettings = syncTargets.length > 0; + const reference = syncTargets[0] ?? availableTargets[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); @@ -578,6 +602,7 @@ function AutoSettleSettingsRows() { const writeToAll = (patch: ServerSettingsPatch) => { for (const environment of syncTargets) { + if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) continue; void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; @@ -589,7 +614,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), + syncEligible: writableEnvironmentIds.has(environment.environmentId), settings: environment.serverConfig?.settings ?? null, capabilities: environment.serverConfig?.environment.capabilities, })), @@ -615,12 +640,14 @@ function AutoSettleSettingsRows() { return ( <> writeToAll({ sidebarAutoSettleOnMerge: value })} /> Days before auto-settle ) : null} + {syncTargets.length < availableTargets.length ? ( + + + {canWriteSettings + ? "Changes apply only to environments this connection can configure." + : "This connection cannot change environment settings."} + + + ) : null} {mismatches.length > 0 ? ( @@ -660,6 +697,7 @@ function AutoSettleSettingsRows() { reference.serverConfig?.environment.capabilities, ); for (const mismatch of mismatches) { + if (!readEnvironmentScope(mismatch.environmentId, AuthSettingsWriteScope)) continue; const target = environments.find( (candidate) => candidate.environmentId === mismatch.environmentId, ); @@ -676,7 +714,9 @@ function AutoSettleSettingsRows() { }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + {syncTargets.length < availableTargets.length ? "Apply settings" : "Apply to all"} + ) : null} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..84716d5f46f0 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -1,13 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; -import type { - EnvironmentId, - ProviderConsumeResetCreditOutcome, - ProviderInstanceId, - ServerProvider, - ServerProviderResetCredits, - ServerProviderUsageWindow, - UsageLimitSourceAccount, - UsageProviderKind, +import { + AuthProvidersManageScope, + type EnvironmentId, + type ProviderConsumeResetCreditOutcome, + type ProviderInstanceId, + type ServerProvider, + type ServerProviderResetCredits, + type ServerProviderUsageWindow, + type UsageLimitSourceAccount, + type UsageProviderKind, } from "@t3tools/contracts"; import { collectLimitSources, @@ -26,6 +27,7 @@ import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { useProviderColors } from "./usageProviders"; @@ -164,6 +166,7 @@ function ResetCredits(props: { readonly now: number; }) { const { environmentId, instanceId, credits, now } = props; + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -182,6 +185,7 @@ function ResetCredits(props: { }`; const redeem = async () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); const result = await consume({ environmentId, input: { instanceId } }); @@ -198,6 +202,7 @@ function ResetCredits(props: { }; const confirm = () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; Alert.alert( "Use a reset credit?", "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", @@ -214,16 +219,21 @@ function ResetCredits(props: { {credits.availableCount > 0 ? ( {busy ? "Using credit…" : "Use a reset credit"} ) : null} + {!canManageProviders ? ( + + This connection cannot manage provider accounts. + + ) : null} {status ? {status} : null} ); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 747ab7c72ee2..5c76ffc84766 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -1,13 +1,47 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; 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 + ); +} + +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("mobile-prepared-connection:empty"), ); diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 28bb924ccca5..d79184af3ae9 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -112,13 +112,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -318,16 +312,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(sessions.cookieName, exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index d8a0e3048c51..e0ad99abcc34 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,3 +1,4 @@ +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual(AuthAdministrativeScopes); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual(AuthAdministrativeScopes); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index d1d21fcdaa67..695596e16469 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,3 +1,4 @@ +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -76,13 +77,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual(AuthStandardClientScopes); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -161,16 +156,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual(AuthAdministrativeScopes); expect(first.subject).toBe("desktop-bootstrap"); expect(second.method).toBe("desktop-bootstrap"); expect(third.method).toBe("desktop-bootstrap"); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..62f7bbed6cf7 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,4 +1,5 @@ import { + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -20,7 +21,7 @@ describe("RPC authorization scopes", () => { AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe( - AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe( AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7bd1ed6c45f1..d16bbbbe1471 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,5 +1,8 @@ import { AuthAccessReadScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -31,36 +34,36 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, - [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, - [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, - [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope, - [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, + [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, + [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, + [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, + [WS_METHODS.providerAuthComplete]: AuthProvidersManageScope, + [WS_METHODS.providerAuthCancel]: AuthProvidersManageScope, + [WS_METHODS.providerAuthLogout]: AuthProvidersManageScope, + [WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope, + [WS_METHODS.providerInstallStart]: AuthProvidersManageScope, + [WS_METHODS.providerInstallCancel]: AuthProvidersManageScope, [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope, - [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, - [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, - [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, - [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, + [WS_METHODS.providerInstallRemove]: AuthProvidersManageScope, + [WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, + [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, + [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, - [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, - [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, + [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, - [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, + [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 97724420f8a9..d1a6f66197da 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId } from "@t3tools/contracts"; +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -183,13 +183,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual(AuthStandardClientScopes); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 919de8b4cc94..6123be6ccf25 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -2,6 +2,9 @@ import { AuthAccessReadScope, AuthAccessWriteScope, AuthStandardClientScopes, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -315,6 +318,9 @@ export const authHttpApiLayer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthEnvironmentMaintainScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 57e6cc98418f..f3596c44a8dd 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -8,6 +8,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + AuthAdministrativeScopes, AuthStandardClientScopes, CommandId, EnvironmentOrchestrationHttpApi, @@ -641,28 +642,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, AuthAdministrativeScopes); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, AuthAdministrativeScopes); assert.equal("token" in (listed[0] ?? {}), false); }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d69fc041a1b8..a18f3880c1ff 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,6 +6,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, @@ -50,7 +51,7 @@ import { } from "@t3tools/shared/dpop"; import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { assert, it } from "@effect/vitest"; +import { assert, expect, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; @@ -495,6 +496,7 @@ const buildAppUnderTest = (options?: { onPairingChangesSubscribed?: Effect.Effect; config?: Partial; layers?: { + processDiagnostics?: Partial; keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; @@ -840,6 +842,7 @@ const buildAppUnderTest = (options?: { signaled: true, message: Option.none(), }), + ...options?.layers?.processDiagnostics, }), ), Layer.provide( @@ -1313,9 +1316,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? AuthAdministrativeScopes.join(" "), ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -2385,10 +2386,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, AuthAdministrativeScopes.join(" ")); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -2406,16 +2404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, AuthAdministrativeScopes); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5895,6 +5884,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { instanceId: providerSetupInstanceId, }).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); assert.deepEqual(observed, providerSetupInstallState); + const refreshed = yield* client[WS_METHODS.serverRefreshProviders]({}); + assert.deepEqual(refreshed.providers, []); const errors = [ yield* client[WS_METHODS.providerInstallStart]({ instanceId: providerSetupInstanceId, @@ -5909,7 +5900,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { for (const error of errors) { assert.equal(error._tag, "EnvironmentAuthorizationError"); if (error._tag === "EnvironmentAuthorizationError") { - assert.equal(error.requiredScope, "orchestration:operate"); + assert.equal(error.requiredScope, "providers:manage"); } } }), @@ -5920,6 +5911,114 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("grants settings, provider management and maintenance independently", () => + Effect.gen(function* () { + let settingsWrites = 0; + let signIns = 0; + let processSignals = 0; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + updateSettings: () => + Effect.sync(() => { + settingsWrites += 1; + return DEFAULT_SERVER_SETTINGS; + }), + }, + providerAuth: { + start: () => + Effect.sync(() => { + signIns += 1; + return providerSetupAuthState; + }), + }, + processDiagnostics: { + signal: (input) => + Effect.sync(() => { + processSignals += 1; + return { ...input, signaled: true, message: Option.none() }; + }), + }, + }, + }); + for (const scope of [ + "orchestration:operate", + "settings:write", + "providers:manage", + "environment:maintain", + "settings:write providers:manage", + ]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const check = (request: Effect.Effect, required: ReadonlyArray) => + Effect.gen(function* () { + const missing = required.find((item) => !scope.split(" ").includes(item)); + if (missing === undefined) { + yield* request; + } else { + const error = yield* request.pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: missing, + }); + } + }); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { environmentIcon: null }, + }), + ["settings:write"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { providerInstances: {} }, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { providers: { codex: { enabled: false } }, usageLimitSources: {} }, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverUpdateSettings]({ + patch: { environmentIcon: null, providerInstances: {} }, + }), + ["settings:write", "providers:manage"], + ); + yield* check( + client[WS_METHODS.providerAuthStart]({ + instanceId: providerSetupInstanceId, + }), + ["providers:manage"], + ); + yield* check( + client[WS_METHODS.serverSignalProcess]({ + pid: 123, + startTimeMs: 1, + signal: "SIGINT", + }), + ["environment:maintain"], + ); + }), + ), + ); + } + assert.equal(settingsWrites, 7); + assert.equal(signIns, 2); + assert.equal(processSignals, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("provider setup binds private sign-in to the authenticated websocket session", () => Effect.gen(function* () { const flowId = "private-sign-in-flow"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6261f7bc5287..79a4a9667c72 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,6 +59,7 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, RpcClientId, + requiredScopesForServerSettingsPatch, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -628,12 +629,13 @@ const makeWsRpcLayer = ( method: string, effect: Effect.Effect, traceAttributes?: Readonly>, - ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); + requiredScopes: ReadonlyArray = [requiredScopeForRpcMethod(method)], + ) => { + const missingScope = requiredScopes.find((scope) => !currentSession.scopes.includes(scope)); + const authorized: Effect.Effect = + missingScope === undefined ? effect : Effect.fail(authorizationError(missingScope)); + return instrumentRpcEffect(method, authorized, traceAttributes); + }; const observeRpcStream = ( method: string, stream: Stream.Stream, @@ -1980,6 +1982,7 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "server", }, + requiredScopesForServerSettingsPatch(patch), ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 11226371172f..fe7131b0a154 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,4 +1,5 @@ import { + AuthSettingsWriteScope, type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, @@ -197,7 +198,10 @@ import { } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, @@ -414,7 +418,7 @@ 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 } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; @@ -1381,6 +1385,9 @@ export default function ChatView(props: ChatViewProps) { const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); + const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { + reportFailure: false, + }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -3528,6 +3535,35 @@ export default function ChatView(props: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand; }): Promise> => { + const previousKeybinding = keybindingValueForCommand( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [], + input.keybindingCommand, + ); + const isDeletingScript = !input.nextScripts.some( + (script) => commandForProjectScript(script.id) === input.keybindingCommand, + ); + const changesKeybinding = + isElectron && + input.keybinding !== undefined && + (input.keybinding?.trim() || null) !== previousKeybinding && + (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); + if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return AsyncResult.failure( + Cause.fail(new Error("This connection cannot change keyboard shortcuts.")), + ); + } + const keybindingRule = changesKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: input.keybinding, + command: input.keybindingCommand, + }) + : null; + const previousTarget = changesKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: previousKeybinding, + command: input.keybindingCommand, + }) + : null; const updateResult = mapAtomCommandResult( await updateProject({ environmentId, @@ -3542,23 +3578,36 @@ export default function ChatView(props: ChatViewProps) { return updateResult; } - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); - - if (isElectron && keybindingRule) { + if (!changesKeybinding) return updateResult; + if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return isDeletingScript + ? updateResult + : AsyncResult.failure( + Cause.fail( + new Error( + "The script was saved, but this connection can no longer change keyboard shortcuts.", + ), + ), + ); + } + if (keybindingRule) { return mapAtomCommandResult( await upsertKeybinding({ environmentId, - input: keybindingRule, + input: previousTarget ? { ...keybindingRule, replace: previousTarget } : keybindingRule, }), () => undefined, ); } + if (previousTarget) { + return mapAtomCommandResult( + await removeKeybinding({ environmentId, input: previousTarget }), + () => undefined, + ); + } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, removeKeybinding, updateProject, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 304922909b0a..cb6ac79a3056 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,8 +1,6 @@ -import type { - ProjectScript, - ResolvedKeybindingsConfig, - T3ProjectFileScript, -} from "@t3tools/contracts"; +import type { EnvironmentId, ProjectScript, T3ProjectFileScript } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -12,6 +10,7 @@ import { useCallback, useMemo, useState } from "react"; import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; +import { serverEnvironment } from "~/state/server"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -40,10 +39,10 @@ export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; interface ProjectScriptsControlProps { + environmentId: EnvironmentId; scripts: ReadonlyArray; /** Scripts declared in the project's checked-in t3.json, offered for import. */ fileScripts?: ReadonlyArray; - keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise; @@ -55,15 +54,18 @@ interface ProjectScriptsControlProps { } export default function ProjectScriptsControl({ + environmentId, scripts, fileScripts = NO_FILE_SCRIPTS, - keybindings, preferredScriptId = null, onRunScript, onAddScript, onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { + const keybindings = + useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? + DEFAULT_RESOLVED_KEYBINDINGS; const [actionsMenuOpen, setActionsMenuOpen] = useState({ scripts: false, imports: false, @@ -288,6 +290,7 @@ export default function ProjectScriptsControl({ )} ({ groups: [] as LocalEnvironmentUpdateGroup[], + permittedEnvironmentIds: new Set(), updateProvider: vi.fn(), })); @@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({ serverEnvironment: { updateProvider: Symbol("updateProvider") }, })); +vi.mock("~/state/session", () => ({ + readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) => + scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId), + useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) => + environmentId !== null && + scope === AuthProvidersManageScope && + testState.permittedEnvironmentIds.has(environmentId), +})); + vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => testState.updateProvider, })); @@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => { beforeEach(() => { vi.useFakeTimers(); hooks.reset(); + testState.permittedEnvironmentIds.clear(); + testState.permittedEnvironmentIds.add(environmentId); testState.updateProvider.mockReset(); const candidate = provider() as ProviderUpdateCandidate; testState.groups = [ diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 56aaa9e96ff0..d2332c2510e2 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -1,6 +1,10 @@ import { CheckIcon } from "lucide-react"; import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; -import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -9,6 +13,7 @@ import { import { cn } from "~/lib/utils"; import { serverEnvironment } from "~/state/server"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; import { @@ -115,6 +120,7 @@ function EnvironmentUpdateRow({ readonly status: ProviderUpdateRowStatus; readonly onUpdate: () => void; }) { + const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope); let trailing: ReactNode; switch (status.kind) { case "loading": @@ -126,14 +132,14 @@ function EnvironmentUpdateRow({ case "failed": case "unchanged": trailing = ( - ); break; default: trailing = ( - ); @@ -145,6 +151,11 @@ function EnvironmentUpdateRow({
{group.label} {status.text} + {!canManageProviders ? ( + + This connection cannot manage provider accounts. + + ) : null}
{trailing}
@@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({ const handleUpdate = useCallback( async (environmentId: EnvironmentId) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; const group = groupByEnvironment.get(environmentId); if (!group || group.candidates.length === 0) { return; diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index a0ed3d80467f..440c1574edc3 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; +import { + AuthProvidersManageScope, + type ProviderDriverKind, + type ProviderInstanceId, +} from "@t3tools/contracts"; import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironment } from "../state/environments"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { @@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set(); type ProviderUpdateToastId = ReturnType; type ActiveProviderUpdateToast = - | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId } + | { + readonly kind: "prompt"; + readonly key: string; + readonly toastId: ProviderUpdateToastId; + readonly canManageProviders: boolean; + } | { readonly kind: "update"; readonly key: string; @@ -105,6 +115,10 @@ export function ProviderUpdatePrimaryNotification() { const navigate = useNavigate(); const providers = useAtomValue(primaryServerProvidersAtom); const primaryEnvironment = usePrimaryEnvironment(); + const canManageProviders = useEnvironmentScope( + primaryEnvironment?.environmentId ?? null, + AuthProvidersManageScope, + ); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); @@ -183,11 +197,15 @@ export function ProviderUpdatePrimaryNotification() { activeToastRef.current = null; } + const updateExistingPrompt = + activeToast?.kind === "prompt" && + activeToast.key === notificationKey && + activeToast.canManageProviders !== canManageProviders; if ( !notificationKey || dismissedNotificationKeys.has(notificationKey) || - seenProviderUpdateNotificationKeys.has(notificationKey) || - activeToastRef.current + (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) || + (activeToastRef.current && !updateExistingPrompt) ) { return; } @@ -204,7 +222,12 @@ export function ProviderUpdatePrimaryNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { + if ( + updateStarted || + oneClickProviders.length === 0 || + !primaryEnvironment || + !readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope) + ) { return; } updateStarted = true; @@ -224,6 +247,19 @@ export function ProviderUpdatePrimaryNotification() { void (async () => { const results = []; for (const provider of oneClickProviders) { + if (!readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)) { + if (activeToastRef.current === activeUpdate) { + addProviderUpdateToast({ + view: getProviderUpdateRejectedToastView( + providerCount, + "This connection cannot manage provider accounts.", + ), + openSettings: openProviderSettings, + }); + activeToastRef.current = null; + } + return; + } results.push( await updateProvider({ environmentId: primaryEnvironment.environmentId, @@ -265,44 +301,52 @@ export function ProviderUpdatePrimaryNotification() { })(); }; - toastId = toastManager.add( - stackedThreadToast({ - type: initialView.type, - title: initialView.title, - description: initialView.description, - timeout: 0, - actionProps: - oneClickProviders.length > 0 - ? { - children: "Update", - onClick: runUpdates, - } - : { + const toastOptions = stackedThreadToast({ + type: initialView.type, + title: initialView.title, + description: canManageProviders + ? initialView.description + : "This connection cannot manage provider accounts. View provider settings for update details.", + timeout: 0, + actionProps: + oneClickProviders.length > 0 + ? { + children: "Update", + disabled: !canManageProviders, + onClick: runUpdates, + } + : { + children: "Settings", + onClick: openSettings, + }, + actionVariant: "outline", + data: { + leadingIcon: + updateProviders.length === 1 ? ( + + ) : undefined, + hideCopyButton: true, + onClose: dismissPrompt, + ...(oneClickProviders.length > 0 + ? { + secondaryActionProps: { children: "Settings", onClick: openSettings, }, - actionVariant: "outline", - data: { - leadingIcon: - updateProviders.length === 1 ? ( - - ) : undefined, - hideCopyButton: true, - onClose: dismissPrompt, - ...(oneClickProviders.length > 0 - ? { - secondaryActionProps: { - children: "Settings", - onClick: openSettings, - }, - secondaryActionVariant: "outline" as const, - } - : {}), - }, - }), - ); - activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; + secondaryActionVariant: "outline" as const, + } + : {}), + }, + }); + if (updateExistingPrompt && activeToast?.kind === "prompt") { + toastId = activeToast.toastId; + toastManager.update(toastId, toastOptions); + } else { + toastId = toastManager.add(toastOptions); + } + activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders }; }, [ + canManageProviders, updateProvider, dismissNotificationKey, dismissedNotificationKeys, diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 67236361f677..612507c7f865 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -9,6 +9,7 @@ const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), continueThreadsAfterServerUpdate: false, + canMaintain: true, })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -20,6 +21,12 @@ vi.mock("~/hooks/useSettings", () => ({ selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }), })); +vi.mock("~/state/session", () => ({ + useEnvironmentScope: (environmentId: EnvironmentId, scope: string) => + testState.canMaintain && environmentId === "env-test" && scope === "environment:maintain", + readEnvironmentScope: (environmentId: EnvironmentId, scope: string) => + testState.canMaintain && environmentId === "env-test" && scope === "environment:maintain", +})); vi.mock("~/state/server", () => ({ serverEnvironment: { updateServer: Symbol("updateServer") }, })); @@ -55,6 +62,15 @@ describe("ServerUpdateAction", () => { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.continueThreadsAfterServerUpdate = false; + testState.canMaintain = true; + }); + + it("does not dispatch an update after maintenance access is removed", async () => { + const action = renderAction(); + testState.canMaintain = false; + action.props.onClick?.(); + await flushPromises(); + expect(testState.updateServer).not.toHaveBeenCalled(); }); it("reports success only after the shared update flow reconnects", async () => { diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 1845ada716b6..3c339358b132 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,3 +1,5 @@ +import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; +import { useEnvironmentScope, readEnvironmentScope } from "~/state/session"; import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { @@ -99,6 +101,7 @@ export function ServerUpdateAction({ readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const canMaintain = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, @@ -125,7 +128,10 @@ export function ServerUpdateAction({ }); const handleUpdate = async () => { - if (pendingUpdateEnvironmentIds.has(environmentId)) { + if ( + !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || + pendingUpdateEnvironmentIds.has(environmentId) + ) { return; } if (isDesktopAppUpdate) { @@ -140,7 +146,10 @@ export function ServerUpdateAction({ return; } } - if (pendingUpdateEnvironmentIds.has(environmentId)) { + if ( + !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || + pendingUpdateEnvironmentIds.has(environmentId) + ) { return; } pendingUpdateEnvironmentIds.add(environmentId); @@ -195,7 +204,12 @@ export function ServerUpdateAction({ } return ( - ); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 98b661bf5ff6..37f7391db452 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -421,9 +421,9 @@ export const ChatHeader = memo(function ChatHeader({ > {activeProjectScripts && ( ()); + function ConfiguredConnectOnboardingDialog() { // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as // signed-out, or its later activation would look like a fresh sign-in. @@ -56,22 +64,20 @@ function ConfiguredConnectOnboardingDialog() { ConnectOnboardingOptOutSchema, ); - const desktopBridge = window.desktopBridge; - const primarySessionState = usePrimarySessionState(); - const currentSessionScopes = desktopBridge - ? AuthAdministrativeScopes - : primarySessionState.data?.authenticated - ? (primarySessionState.data.scopes ?? null) - : null; - const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + const { isReady: environmentsReady } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primarySessionState = useAtomValue( + primaryEnvironmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(primaryEnvironmentId), + ); + const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); // The publish step is only offered when we know the answer; opening the // wizard before the session state resolves would let the step set change // mid-flight. A failed session read still opens the wizard — it just means // no publish step. const sessionScopesKnown = - Boolean(desktopBridge) || - primarySessionState.data !== null || - primarySessionState.error !== null; + primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; const controller = useCloudLinkController(); const showPublishStep = canManageRelay && controller.linkState.target !== null; @@ -89,6 +95,12 @@ function ConfiguredConnectOnboardingDialog() { const prefilledFromLinkStateRef = useRef(false); const observedAccountRef = useRef(undefined); + useEffect(() => { + if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) { + setStep("devices"); + } + }, [isApplying, sessionScopesKnown, showPublishStep, step]); + const optOutAccounts = optOutState.optOutAccounts; // Every sign-in or account switch that completes during this session @@ -185,6 +197,14 @@ function ConfiguredConnectOnboardingDialog() { }; const applyPublishSelection = async () => { + if (isApplying) return; + if ( + primaryEnvironmentId === null || + !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) + ) { + setStep("devices"); + return; + } // The wizard only ever enables — with both toggles off there is nothing to // apply, and an existing link must not be torn down from onboarding. if (!exposeEnvironment && !publishAgentActivity) { @@ -229,7 +249,15 @@ function ConfiguredConnectOnboardingDialog() { steps={steps} currentStep={step} disabled={isApplying} - onStepSelect={setStep} + onStepSelect={(nextStep) => { + if ( + nextStep === "publish" && + (primaryEnvironmentId === null || + !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)) + ) + return; + setStep(nextStep); + }} /> ) : null} @@ -238,10 +266,22 @@ function ConfiguredConnectOnboardingDialog() { { + if ( + primaryEnvironmentId !== null && + readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) + ) + setExposeEnvironment(enabled); + }} + onPublishAgentActivityChange={(enabled) => { + if ( + primaryEnvironmentId !== null && + readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) + ) + setPublishAgentActivity(enabled); + }} /> ) : ( @@ -263,7 +303,9 @@ function ConfiguredConnectOnboardingDialog() { @@ -1132,36 +1176,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.

@@ -1177,7 +1229,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio Cancel } /> - Send SIGINT + + {canMaintainEnvironment ? "Send SIGINT" : "This connection cannot manage processes."} + onSignal(process.pid, "SIGKILL")} > @@ -353,7 +359,9 @@ function ProcessSignalActions({ } /> - Send SIGKILL + + {canMaintainEnvironment ? "Send SIGKILL" : "This connection cannot manage processes."} + ); @@ -361,11 +369,13 @@ function ProcessSignalActions({ function ProcessDiagnosticsTable({ processes, + canMaintainEnvironment, signalingPid, onSignal, emptyLabel, }: { processes: ReadonlyArray; + canMaintainEnvironment: boolean; signalingPid: number | null; onSignal: (pid: number, signal: ServerProcessSignal) => void; emptyLabel?: string; @@ -475,6 +485,7 @@ function ProcessDiagnosticsTable({ @@ -780,6 +791,7 @@ export function DiagnosticsSettingsPanel() { const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); const primaryEnvironment = usePrimaryEnvironment(); const environmentId = primaryEnvironment?.environmentId ?? null; + const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -868,6 +880,12 @@ export function DiagnosticsSettingsPanel() { const isProcessInitialLoading = isProcessPending && processData === null; const signalProcess = useCallback( async (pid: number, signal: ServerProcessSignal) => { + const targetEnvironmentId = environmentIdRef.current; + if ( + targetEnvironmentId === null || + !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope) + ) + return; if (signalingPidRef.current !== null) return; signalingPidRef.current = pid; setSignalingPid(pid); @@ -897,7 +915,11 @@ export function DiagnosticsSettingsPanel() { } } const currentEnvironmentId = environmentIdRef.current; - if (currentEnvironmentId === null) { + if ( + currentEnvironmentId === null || + currentEnvironmentId !== targetEnvironmentId || + !readEnvironmentScope(currentEnvironmentId, AuthEnvironmentMaintainScope) + ) { clearSignaling(); return; } @@ -1013,6 +1035,7 @@ export function DiagnosticsSettingsPanel() { ) : null} { ).toMatch(/cannot change/); }); - it("stays open while access is still resolving so a slow session does not flicker", () => { + it("waits for a settings grant before allowing changes", () => { expect( resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }), - ).toBeNull(); + ).toMatch(/cannot change/); expect( resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }), ).toBeNull(); diff --git a/apps/web/src/components/settings/EnvironmentIconPicker.tsx b/apps/web/src/components/settings/EnvironmentIconPicker.tsx index f990aa5a2441..1593fa958104 100644 --- a/apps/web/src/components/settings/EnvironmentIconPicker.tsx +++ b/apps/web/src/components/settings/EnvironmentIconPicker.tsx @@ -1,4 +1,5 @@ import { + AuthSettingsWriteScope, ENVIRONMENT_MACHINE_KINDS, isEnvironmentMachineKind, resolveEnvironmentMachineKind, @@ -7,18 +8,11 @@ import { } from "@t3tools/contracts"; import { useCallback } from "react"; -import { isElectron } from "../../env"; -import { usePrimarySessionState } from "../../environments/primary"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; -import { usePrimaryEnvironmentId } from "../../state/environments"; -import { useEnvironmentSessionState } from "../../state/session"; +import { useEnvironmentScope } from "../../state/session"; import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - resolvePrimaryOperateAccess, - resolveRemoteOperateAccess, -} from "./ProviderSettingsPanel.logic"; const AUTOMATIC_VALUE = "automatic"; @@ -36,37 +30,12 @@ export function resolveEnvironmentIconPickerLock(input: { if (input.serverConfig.environment.capabilities.environmentIcon !== true) { return "This environment's server is too old to keep an icon. Update it to choose one."; } - if (input.operateAccess === "denied") { + if (input.operateAccess !== "granted") { return "Your session on this environment cannot change its settings."; } return null; } -// Same split the provider settings use: the desktop app owns its primary -// server outright, a browser session on the primary checks its cookie -// session's scopes, and a remote checks the scopes its own server reports. -function useEnvironmentOperateAccess(environmentId: EnvironmentId) { - const isPrimary = usePrimaryEnvironmentId() === environmentId; - const primarySession = usePrimarySessionState(); - const remoteSession = useEnvironmentSessionState(environmentId); - if (isPrimary) { - return isElectron - ? "granted" - : resolvePrimaryOperateAccess({ - isPrimary: true, - hasDesktopBridge: false, - session: primarySession.data, - isPending: primarySession.isPending, - hasError: primarySession.error !== null, - }); - } - return resolveRemoteOperateAccess({ - session: remoteSession.data, - isPending: remoteSession.isPending, - hasError: remoteSession.hasError, - }); -} - /** * Picks the machine glyph an environment wears everywhere it is listed. * "Automatic" clears the override so the server's own detection shows @@ -85,7 +54,9 @@ export function EnvironmentIconPicker({ readonly size?: "xs" | "sm"; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); - const operateAccess = useEnvironmentOperateAccess(environmentId); + const operateAccess = useEnvironmentScope(environmentId, AuthSettingsWriteScope) + ? "granted" + : "denied"; const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess }); const override = serverConfig?.settings.environmentIcon ?? null; const detected = serverConfig?.environment.platform.machine ?? null; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index ad99328647af..9d80fb58279e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -21,6 +21,7 @@ import { useState, } from "react"; import { + AuthSettingsWriteScope, type KeybindingCommand, type KeybindingWhenNode, type ServerRemoveKeybindingInput, @@ -42,6 +43,7 @@ import { primaryServerKeybindingsConfigPathAtom, serverEnvironment, } from "../../state/server"; +import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { usePrimaryEnvironment } from "../../state/environments"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; @@ -1339,6 +1341,10 @@ export function KeybindingsSettingsPanel() { const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); const primaryEnvironment = usePrimaryEnvironment(); + const canWriteSettings = useEnvironmentScope( + primaryEnvironment?.environmentId ?? null, + AuthSettingsWriteScope, + ); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1402,7 +1408,11 @@ export function KeybindingsSettingsPanel() { const saveKeybinding = useCallback( (input: ServerUpsertKeybindingInput) => { - if (!primaryEnvironment) return; + if ( + !primaryEnvironment || + !readEnvironmentScope(primaryEnvironment.environmentId, AuthSettingsWriteScope) + ) + return; setSavingCommand(input.command); const payload: ServerUpsertKeybindingInput = { command: input.command, @@ -1435,7 +1445,11 @@ export function KeybindingsSettingsPanel() { const removeKeybinding = useCallback( (row: KeybindingRow) => { - if (!primaryEnvironment) return; + if ( + !primaryEnvironment || + !readEnvironmentScope(primaryEnvironment.environmentId, AuthSettingsWriteScope) + ) + return; setSavingCommand(row.command); void (async () => { const result = await removeKeybindingMutation({ @@ -1516,6 +1530,7 @@ export function KeybindingsSettingsPanel() { type="button" size="icon-xs" variant="ghost-muted" + disabled={!canWriteSettings} onClick={() => setIsAddingBinding(true)} aria-label="Add keybinding" > @@ -1547,7 +1562,14 @@ export function KeybindingsSettingsPanel() { > {!isElectron ? : null} - + {!canWriteSettings ? ( +

+ This connection can view keybindings but cannot change them. +

+ ) : null} +
+ +
); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..3ebb95987bd6 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,14 +12,15 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + AuthSettingsWriteScope, + type ContextMenuItem, + type ModelSelection, + type ProjectIconOverride, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; @@ -74,6 +75,7 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environmen import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -558,6 +560,28 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // Captured before the write so a cleared or deleted binding can be // removed from the keybindings config afterwards. const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); + const isDeletingScript = !nextScripts.some( + (script) => commandForProjectScript(script.id) === keybindingCommand, + ); + const changesKeybinding = + isElectron && + keybinding !== undefined && + (keybinding?.trim() || null) !== previousKeybinding && + (!isDeletingScript || + readEnvironmentScope(selectedCheckout.environmentId, AuthSettingsWriteScope)); + if ( + changesKeybinding && + !readEnvironmentScope(selectedCheckout.environmentId, AuthSettingsWriteScope) + ) { + const result = AsyncResult.failure( + Cause.fail(new Error("This connection cannot change keyboard shortcuts.")), + ); + reportFailure("Failed to save scripts", result); + return result; + } + const keybindingRule = changesKeybinding + ? decodeProjectScriptKeybindingRule({ keybinding, command: keybindingCommand }) + : null; const updateResult = mapAtomCommandResult( await updateProject({ environmentId: selectedCheckout.environmentId, @@ -570,11 +594,19 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return updateResult; } - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; + if (!changesKeybinding) return updateResult; + if (!readEnvironmentScope(selectedCheckout.environmentId, AuthSettingsWriteScope)) { + if (isDeletingScript) return updateResult; + const result = AsyncResult.failure( + Cause.fail( + new Error( + "The script was saved, but this connection can no longer change keyboard shortcuts.", + ), + ), + ); + reportFailure("Failed to save keybinding", result); + return result; + } const environmentIds = [selectedCheckout.environmentId]; const previousTarget = previousKeybinding ? decodeProjectScriptKeybindingRule({ @@ -1266,6 +1298,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { {driverOption !== undefined ? ( - +
; + readonly canManageCustomModels: boolean; /** Server-returned model slugs hidden from the model picker. */ readonly hiddenModels: ReadonlyArray; /** Model slugs favorited for this provider instance. */ @@ -148,6 +149,7 @@ export function ProviderModelsSection({ driverKind, models, customModels, + canManageCustomModels, hiddenModels, favoriteModels, modelOrder, @@ -206,7 +208,7 @@ export function ProviderModelsSection({ }, [displayModels]); const handleAdd = () => { - if (driverKind === "antigravity") return; + if (!canManageCustomModels || driverKind === "antigravity") return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); @@ -242,6 +244,7 @@ export function ProviderModelsSection({ }; const handleRemove = (slug: string) => { + if (!canManageCustomModels) return; if (editingSlug === slug) setEditingSlug(null); onChange(customModels.filter((entry) => entry.slug !== slug)); onModelOrderChange(modelOrder.filter((model) => model !== slug)); @@ -250,6 +253,7 @@ export function ProviderModelsSection({ }; const handleSaveEdit = (next: CustomModelDefinition) => { + if (!canManageCustomModels) return; onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); setEditingSlug(null); }; @@ -372,10 +376,12 @@ export function ProviderModelsSection({
- {driverKind === "antigravity" ? null : isAdding ? ( + {driverKind === "antigravity" || !canManageCustomModels ? null : isAdding ? (
({ const commands = vi.hoisted(() => ({ refresh: vi.fn(), updateProvider: vi.fn(), + canManageProviders: true, + canWriteSettings: true, })); const settingsState = vi.hoisted(() => ({ @@ -97,6 +99,20 @@ vi.mock("../../environments/primary", () => ({ vi.mock("../../state/session", () => ({ useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), + useEnvironmentScope: (environmentId: EnvironmentId, scope: string) => + environmentId === "remote-device" && + (scope === "providers:manage" + ? commands.canManageProviders + : scope === "settings:write" + ? commands.canWriteSettings + : scope === "orchestration:read"), + readEnvironmentScope: (environmentId: EnvironmentId, scope: string) => + environmentId === "remote-device" && + (scope === "providers:manage" + ? commands.canManageProviders + : scope === "settings:write" + ? commands.canWriteSettings + : scope === "orchestration:read"), })); import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; @@ -179,6 +195,8 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.updateSettings.mockReset(); settingsSearchState.targetId = null; settingsSearchState.effects = []; + commands.canManageProviders = true; + commands.canWriteSettings = true; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); }); @@ -238,6 +256,7 @@ describe("EnvironmentProviderSettings routing", () => { }); it("keeps provider selection available while write controls are read only", () => { + commands.canWriteSettings = false; settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, providerInstances: { @@ -271,7 +290,7 @@ describe("EnvironmentProviderSettings routing", () => { const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); expect(notice).not.toBeNull(); - expect(visitElements(panel, isRefreshButton)).toBeNull(); + expect(visitElements(panel, isRefreshButton)).not.toBeNull(); expect(visitElements(panel, isAddProviderButton)).toBeNull(); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index c04db646a47e..d32634004ed9 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -1,4 +1,4 @@ -import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; +import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -136,7 +136,7 @@ describe("provider environment access", () => { describe("primary operate access", () => { const authenticated = { authenticated: true as const, - scopes: [AuthOrchestrationOperateScope], + scopes: [AuthProvidersManageScope], }; it("keeps cached session data authoritative while SWR revalidates", () => { @@ -163,7 +163,7 @@ describe("primary operate access", () => { ).toBe("pending"); }); - it("treats a failed session fetch as a transport problem, not a denial", () => { + it("denies writes when the session fetch fails", () => { expect( resolvePrimaryOperateAccess({ isPrimary: true, @@ -172,7 +172,7 @@ describe("primary operate access", () => { isPending: false, hasError: true, }), - ).toBe("granted"); + ).toBe("denied"); }); it("denies unauthenticated sessions and sessions without the operate scope", () => { @@ -205,7 +205,7 @@ describe("primary operate access", () => { ).toBe("denied"); }); - it("grants desktop bridge and remote environments without blocking on the primary session", () => { + it("waits for explicit grants on desktop and remote environments", () => { expect( resolvePrimaryOperateAccess({ isPrimary: true, @@ -214,7 +214,7 @@ describe("primary operate access", () => { isPending: true, hasError: false, }), - ).toBe("granted"); + ).toBe("pending"); expect( resolvePrimaryOperateAccess({ isPrimary: false, @@ -223,15 +223,24 @@ describe("primary operate access", () => { isPending: true, hasError: false, }), - ).toBe("granted"); + ).toBe("pending"); }); }); describe("remote operate access", () => { + it("does not treat the old orchestration grant as provider management", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:operate"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); it("derives access from the environment session's granted scopes", () => { expect( resolveRemoteOperateAccess({ - session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + session: { authenticated: true, scopes: [AuthProvidersManageScope] }, isPending: false, hasError: false, }), @@ -258,18 +267,16 @@ describe("remote operate access", () => { ); expect( resolveRemoteOperateAccess({ - session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + session: { authenticated: true, scopes: [AuthProvidersManageScope] }, isPending: true, hasError: false, }), ).toBe("granted"); }); - it("stays optimistic when the session fetch fails or an older server omits scopes", () => { - // Transport failures and pre-scope-reporting servers are not permission - // decisions; the environment RPC layer still rejects unauthorized writes. + it("denies writes when the session fetch fails or scopes are missing", () => { expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( - "granted", + "denied", ); expect( resolveRemoteOperateAccess({ @@ -277,6 +284,6 @@ describe("remote operate access", () => { isPending: false, hasError: false, }), - ).toBe("granted"); + ).toBe("denied"); }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index b415b5f69b07..d0e3c6e96bb7 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -1,6 +1,6 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { - AuthOrchestrationOperateScope, + AuthProvidersManageScope, type AuthSessionState, type EnvironmentId, } from "@t3tools/contracts"; @@ -70,46 +70,19 @@ export type ProviderEnvironmentAccess = */ export type ProviderOperateAccess = "granted" | "denied" | "pending"; -/** - * Resolve operate access from an environment's `/api/auth/session` answer. - * - * Cached session data wins over an in-flight revalidation. The session atoms - * are SWR-backed, so they report `isPending` on every background refresh; - * treating that as unknown would flip a working panel back to loading and - * discard in-progress edits. - * - * `missingScopesAccess` decides the case where the session resolved but did - * not report scopes: the primary serves the web app itself so its server - * always reports them (absence means denial), while a remote device may run an - * older server version that predates scope reporting, where denial would lock - * out a legitimate session. The environment RPC layer stays authoritative - * either way. - */ +/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */ function resolveSessionOperateAccess(input: { readonly session: Pick | null; readonly isPending: boolean; readonly hasError: boolean; - readonly missingScopesAccess: "granted" | "denied"; }): ProviderOperateAccess { - if (input.session === null) { - if (input.isPending) { - return "pending"; - } - // A failed session fetch is a transport problem, not a permission - // decision — locking the panel read-only would misreport it. Stay - // optimistic; the environment RPC layer still rejects unauthorized writes. - return input.hasError ? "granted" : "denied"; - } - if (!input.session.authenticated) { - return "denied"; - } - if (input.session.scopes === undefined) { - return input.missingScopesAccess; - } - return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; + if (input.hasError) return "denied"; + if (input.session === null) return input.isPending ? "pending" : "denied"; + return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope) + ? "granted" + : "denied"; } -/** Operate access for the primary environment's own browser session. */ export function resolvePrimaryOperateAccess(input: { readonly isPrimary: boolean; readonly hasDesktopBridge: boolean; @@ -117,30 +90,15 @@ export function resolvePrimaryOperateAccess(input: { readonly isPending: boolean; readonly hasError: boolean; }): ProviderOperateAccess { - if (!input.isPrimary || input.hasDesktopBridge) { - return "granted"; - } - return resolveSessionOperateAccess({ - session: input.session, - isPending: input.isPending, - hasError: input.hasError, - missingScopesAccess: "denied", - }); + return resolveSessionOperateAccess(input); } -/** - * Operate access for a non-primary environment, derived from the scopes its - * `/api/auth/session` endpoint reports for this client's credential. - */ export function resolveRemoteOperateAccess(input: { readonly session: Pick | null; readonly isPending: boolean; readonly hasError: boolean; }): ProviderOperateAccess { - return resolveSessionOperateAccess({ - ...input, - missingScopesAccess: "granted", - }); + return resolveSessionOperateAccess(input); } export function classifyProviderEnvironmentAccess(input: { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fb4620b054c1..79323fffd663 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1,3 +1,9 @@ +import { + AuthSettingsWriteScope, + AuthProvidersManageScope, + AuthOrchestrationReadScope, +} from "@t3tools/contracts"; +import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -28,8 +34,6 @@ import { PlusIcon, RefreshCwIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; -import { isElectron } from "../../env"; -import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { cn } from "../../lib/utils"; @@ -102,7 +106,6 @@ import { isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, type ProviderOperateAccess, - resolvePrimaryOperateAccess, resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -419,28 +422,6 @@ function SelectedEnvironmentProviderSettings({ readonly deviceTabs?: ReactNode; readonly targetInstanceId?: ProviderInstanceId | undefined; }) { - const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - if (isPrimary) { - // The desktop app owns its primary server outright; a browser session - // checks the scopes its cookie session was granted. - if (isElectron) { - return ( - - ); - } - return ( - - ); - } return ( - ); -} - function RemoteSessionGatedProviderSettings({ environment, deviceTabs, @@ -551,13 +505,15 @@ export function EnvironmentProviderSettings({ readonly targetInstanceId?: ProviderInstanceId | undefined; /** * Grey out and freeze every write control when this session's credential - * lacks `orchestration:operate` on the environment. Selecting providers + * lacks `providers:manage` on the environment. Selecting providers * still works so the real configuration stays readable; switches, forms, - * and the health interval are inert so no write is offered and then rejected. + * are inert so no write is offered and then rejected. */ readonly readOnly?: boolean; }) { const settings = useEnvironmentSettings(environmentId); + const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); + const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); const updateSettings = useUpdateEnvironmentSettings(environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; @@ -614,7 +570,8 @@ export function EnvironmentProviderSettings({ : null; const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; + if (refreshingRef.current || !readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) + return; refreshingRef.current = true; setIsRefreshingProviders(true); void (async () => { @@ -636,6 +593,7 @@ export function EnvironmentProviderSettings({ const runProviderUpdate = useCallback( async (candidate: ProviderSettingsUpdateCandidate) => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; // Ref-based re-entry guard, mirroring refreshProviders: a state updater // may run after this function returns, so it cannot gate the dispatch. if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { @@ -916,7 +874,10 @@ export function EnvironmentProviderSettings({ onUpdate={(next) => { const wasEnabled = resolveProviderInstanceEnabled(row.instance); const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + const shouldClearTextGen = + isDisabling && + textGenInstanceId === row.instanceId && + readEnvironmentScope(environmentId, AuthSettingsWriteScope); updateProviderInstance( row, next, @@ -977,53 +938,47 @@ export function EnvironmentProviderSettings({
{deviceTabs}
- {readOnly ? ( - - - - ) : ( - <> - - void refreshProviders()} - > - - Refresh provider status - - {isRefreshingProviders ? ( - "Refreshing providers" - ) : ( - - )} - - - } - /> - 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}
{readOnly ? ( @@ -1089,7 +1044,10 @@ export function EnvironmentProviderSettings({ description="Refresh provider status, versions, and models in the background. Set to 0 to disable." resetAction={ providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - + @@ -1107,11 +1065,11 @@ export function EnvironmentProviderSettings({ } control={
; onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void; }) { @@ -539,7 +543,8 @@ function ProcessActions({
@@ -1264,6 +1303,7 @@ export function ResourceTelemetryDiagnostics() {
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..ccc160d33763 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,3 +1,6 @@ +import { AuthSettingsWriteScope } from "@t3tools/contracts"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentScope } from "../../state/session"; import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { @@ -252,7 +255,12 @@ export function SettingsRow({ }) { const targetRef = useSettingsSearchTarget(rowProps.id); const primarySettingsAvailable = usePrimarySettingsAvailable(); - const unavailable = serverScoped && !primarySettingsAvailable; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const canWriteSettings = useEnvironmentScope( + serverScoped ? primaryEnvironmentId : null, + AuthSettingsWriteScope, + ); + const unavailable = serverScoped && (!primarySettingsAvailable || !canWriteSettings); const renderedReset = unavailable ? null : resetAction; const renderedControl = unavailable && control ? ( @@ -271,7 +279,9 @@ export function SettingsRow({
- {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} + {primarySettingsAvailable + ? "This connection does not have permission to change environment settings." + : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} ) : ( diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7358ed8f9a17..cc5774b3a95c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -443,7 +443,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", targetId: "connections-environment", searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], - localBackendManagementOnly: true, + primaryOnly: true, }, { id: "network-access", diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index a2f5ca62766f..85f005d3ee90 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; -import { AuthAccessWriteScope } from "@t3tools/contracts"; +import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; -import { usePrimarySessionState } from "~/environments/primary"; +import { useEnvironmentScope } from "~/state/session"; import { primaryServerConfigAtom } from "~/state/server"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; @@ -16,14 +16,14 @@ import { filterAvailableSettingsSearchItems } from "./settingsSearch"; export function useAvailableSettingsSearchItems() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); - const primarySessionState = usePrimarySessionState(); const primaryServerConfig = useAtomValue(primaryServerConfigAtom); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); - const canManageLocalBackend = - isElectron || - ((primarySessionState.data?.authenticated && - primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? - false); + const canManageLocalBackend = useEnvironmentScope( + primaryEnvironmentId, + AuthEnvironmentMaintainScope, + ); + const desktopWsl = useEnvironmentQuery( + isElectron && canManageLocalBackend ? desktopWslStateAtom : null, + ); return useMemo( () => diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 77f85953984c..005f58b3d448 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -1,4 +1,5 @@ import { + AuthProvidersManageScope, type EnvironmentId, type ProviderConsumeResetCreditOutcome, ProviderInstanceId, @@ -26,6 +27,7 @@ import { Fragment, useState } from "react"; import { usePrimarySettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; +import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; @@ -303,6 +305,7 @@ function ResetCredits({ readonly credits: ServerProviderResetCredits; readonly now: number; }) { + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); @@ -321,6 +324,7 @@ function ResetCredits({ const redeem = async () => { setConfirming(false); + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setBusy(true); setStatus(null); const result = await consume({ environmentId, input: { instanceId } }); @@ -340,7 +344,14 @@ function ResetCredits({
{summary} {credits.availableCount > 0 ? ( - ) : null} @@ -356,7 +367,9 @@ function ResetCredits({ }>Cancel - + diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 9b428b08ea50..68083d7957c6 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, ServerSettings, type ServerSettingsPatch, @@ -41,12 +43,15 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/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"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -411,6 +416,27 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } +/** Environments that can receive a shared settings write right now. */ +function useSharedSettingsSyncTargetIds(): ReadonlyArray { + const { environments } = useEnvironments(); + const writableTargetsAtom = useMemo( + () => + Atom.make((get) => + environments.filter(supportsSharedSettingsSync).flatMap((environment) => { + // Subscribe before offering sync so the first action sees each target's grant. + const result = get(environmentSession.sessionStateAtom(environment.environmentId)); + const session = + result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)); + return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope) + ? [environment.environmentId] + : []; + }), + ), + [environments], + ); + return useAtomValue(writableTargetsAtom); +} + /** * Returns an updater that routes each key to the correct backing store. * @@ -421,6 +447,8 @@ 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", @@ -430,7 +458,19 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { (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) => @@ -464,6 +504,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { target?.serverConfig?.environment.capabilities, ); if (Object.keys(targetPatch).length === 0) continue; + if ( + !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) => + readEnvironmentScope(targetId, scope), + ) + ) + continue; wroteToTarget = true; void persistServerSettings({ environmentId: targetId, @@ -506,6 +552,7 @@ export function useSharedSettingsSync() { ? (primaryEnvironment.serverConfig?.settings ?? null) : null; const { environments } = useEnvironments(); + const writableTargetIds = useSharedSettingsSyncTargetIds(); const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", @@ -520,12 +567,14 @@ export function useSharedSettingsSync() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), + syncEligible: + supportsSharedSettingsSync(environment) && + writableTargetIds.includes(environment.environmentId), settings: environment.serverConfig?.settings ?? null, capabilities: environment.serverConfig?.environment.capabilities, })), }), - [environments, primaryEnvironmentId, primarySettings, primaryCapabilities], + [environments, primaryEnvironmentId, primarySettings, primaryCapabilities, writableTargetIds], ); const applyToAll = useCallback(() => { @@ -537,6 +586,12 @@ export function useSharedSettingsSync() { const target = environments.find( (candidate) => candidate.environmentId === mismatch.environmentId, ); + if ( + !requiredScopesForServerSettingsPatch(patch).every((scope) => + readEnvironmentScope(mismatch.environmentId, scope), + ) + ) + continue; void persistServerSettings({ environmentId: mismatch.environmentId, input: { diff --git a/apps/web/src/lib/resourceTelemetryState.ts b/apps/web/src/lib/resourceTelemetryState.ts index 47ca79898dfb..de70ab7c733b 100644 --- a/apps/web/src/lib/resourceTelemetryState.ts +++ b/apps/web/src/lib/resourceTelemetryState.ts @@ -1,10 +1,15 @@ -import type { ResourceTelemetryHistoryInput, ResourceTelemetrySnapshot } from "@t3tools/contracts"; +import { + AuthEnvironmentMaintainScope, + type ResourceTelemetryHistoryInput, + type ResourceTelemetrySnapshot, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; 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 { @@ -30,6 +35,9 @@ export function useResourceTelemetry(): ResourceTelemetryState { 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/state/session.ts b/apps/web/src/state/session.ts index a7d5a53d10d2..45bab2d17a0c 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,6 +1,6 @@ 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 { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -9,6 +9,39 @@ 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 + ); +} + +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/docs/user/remote-access.md b/docs/user/remote-access.md index c0e25b709c2e..c5e2b723a0cb 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -154,6 +154,12 @@ 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. +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/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index f4cbee0f6885..51ff6e648431 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, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 623780c1fb8b..49262e719d91 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,3 +1,8 @@ +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"; @@ -1176,6 +1181,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({ appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), From 1be8e080532d82f796d4b201ae7f822c2a8cb800 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:14:50 -0700 Subject: [PATCH 02/39] fix(web): explain maintenance permissions with tooltips --- .../settings/ResourceTelemetryDiagnostics.tsx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index b748aab5995a..2fb7f5c97e28 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -541,24 +541,36 @@ function ProcessActions({ const isSignaling = signalingKeys.has(processIdentityKey(process)); return (
- - + + }> + + + + {canMaintainEnvironment ? "Send SIGINT" : "This connection cannot manage processes."} + + + + }> + + + + {canMaintainEnvironment ? "Send SIGKILL" : "This connection cannot manage processes."} + +
); } @@ -1123,20 +1135,24 @@ export function ResourceTelemetryDiagnostics() { icon={} headerAction={ collectorNeedsRetry ? ( - + + }> + + + + {canMaintainEnvironment + ? "Restart the resource monitor." + : "This connection cannot restart the resource monitor."} + + ) : null } > From 11778e32dc033e95bcfa6b088d63c570e8888716 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 14:25:58 -0700 Subject: [PATCH 03/39] fix(auth): respect independent environment grants in settings --- apps/web/src/cloud/primaryCloudLinkState.ts | 21 +- .../src/cloud/useCloudLinkController.test.tsx | 216 ++++++++++++++++++ apps/web/src/cloud/useCloudLinkController.ts | 30 ++- .../cloud/ConnectOnboardingDialog.tsx | 21 +- .../ConnectionsSettings.logic.test.ts | 13 ++ .../settings/ConnectionsSettings.logic.ts | 7 + .../settings/ConnectionsSettings.tsx | 62 ++++- apps/web/src/planAgentSelectionHeal.test.tsx | 87 +++++++ apps/web/src/planAgentSelectionHeal.tsx | 8 +- 9 files changed, 435 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/cloud/useCloudLinkController.test.tsx create mode 100644 apps/web/src/planAgentSelectionHeal.test.tsx diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..41cc9fc25967 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; +import { AuthRelayReadScope, 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 +12,7 @@ import { useCallback, useMemo } from "react"; import { usePrimaryEnvironment } from "../state/environments"; import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( @@ -43,13 +44,20 @@ function targetKey(target: CloudLinkTarget): string { } export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { - if (target) { + if (target && readEnvironmentScope(target.environmentId, AuthRelayReadScope)) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } } +export function readCachedPrimaryCloudLinkState(target: CloudLinkTarget) { + if (!readEnvironmentScope(target.environmentId, AuthRelayReadScope)) return null; + const result = appAtomRegistry.get(primaryCloudLinkStateAtom(targetKey(target))); + return result._tag === "Success" ? result.value : null; +} + export function usePrimaryCloudLinkState() { const primary = usePrimaryEnvironment(); + const canReadRelay = useEnvironmentScope(primary?.environmentId ?? null, AuthRelayReadScope); const target = useMemo( () => primary?.entry.target._tag === "PrimaryConnectionTarget" @@ -62,9 +70,10 @@ export function usePrimaryCloudLinkState() { : null, [primary], ); - const atom = target - ? primaryCloudLinkStateAtom(targetKey(target)) - : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; + const atom = + target && canReadRelay + ? primaryCloudLinkStateAtom(targetKey(target)) + : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); const refresh = useCallback(() => { refreshPrimaryCloudLinkState(target); @@ -76,7 +85,7 @@ export function usePrimaryCloudLinkState() { } return { - data: Option.getOrNull(AsyncResult.value(result)), + data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), error, isPending: result.waiting, refresh, diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx new file mode 100644 index 000000000000..a44e2b59fec7 --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -0,0 +1,216 @@ +import { + AuthRelayReadScope, + AuthRelayWriteScope, + EnvironmentId, + type EnvironmentCloudLinkStateResult, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + scopes: new Set(), + snapshot: null as EnvironmentCloudLinkStateResult | null, + renderedSnapshot: null as EnvironmentCloudLinkStateResult | null, + getToken: vi.fn<() => Promise>(), + link: vi.fn(), + unlink: vi.fn(), + preferences: vi.fn(), + refreshDiscovery: vi.fn(), + refreshLink: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@clerk/react", () => ({ + useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }), +})); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } })); +vi.mock("../state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery }, +})); +vi.mock("../state/session", () => ({ + readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope), +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./linkEnvironmentAtoms", () => ({ + linkPrimaryEnvironment: testState.link, + unlinkPrimaryEnvironment: testState.unlink, + updatePrimaryEnvironmentPreferences: testState.preferences, +})); +vi.mock("./primaryCloudLinkState", () => ({ + readCachedPrimaryCloudLinkState: () => testState.snapshot, + usePrimaryCloudLinkState: () => ({ + target, + data: testState.renderedSnapshot, + error: null, + isPending: false, + refresh: testState.refreshLink, + }), +})); +vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })); + +import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController"; + +const target = { + environmentId: EnvironmentId.make("primary"), + label: "Primary", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773/ws", +}; +const linkedState: EnvironmentCloudLinkStateResult = { + linked: true, + cloudUserId: "account-1", + relayUrl: "https://relay.example.com", + relayIssuer: "https://relay.example.com", + managedTunnelActive: true, + publishAgentActivity: false, +}; + +let renderer: ReactTestRenderer | null = null; +let controller: ReturnType | null = null; + +function ControllerProbe() { + const value = useCloudLinkController(); + useLayoutEffect(() => { + controller = value; + }); + return null; +} + +async function mountController() { + await act(() => { + renderer = create(); + }); +} + +async function reconcile(desired: CloudLinkDesiredState) { + let succeeded = false; + await act(async () => { + if (controller === null) throw new Error("Controller is not mounted."); + succeeded = await controller.reconcileCloudState(desired); + }); + return succeeded; +} + +function expectNoMutations() { + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(console, "error").mockImplementation(() => undefined); + testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]); + testState.snapshot = linkedState; + testState.renderedSnapshot = linkedState; + testState.getToken.mockReset().mockResolvedValue("clerk-token"); + for (const command of [ + testState.link, + testState.unlink, + testState.preferences, + testState.refreshDiscovery, + ]) { + command.mockReset().mockResolvedValue(AsyncResult.success({})); + } + testState.refreshLink.mockReset(); + testState.toast.mockReset(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + controller = null; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("useCloudLinkController", () => { + it.each([AuthRelayReadScope, AuthRelayWriteScope])( + "does not mutate without %s, even with a cached link snapshot", + async (scope) => { + await mountController(); + testState.scopes.delete(scope); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expect(testState.getToken).not.toHaveBeenCalled(); + expectNoMutations(); + }, + ); + + it("does not interpret unavailable link state as an unlinked environment", async () => { + testState.snapshot = null; + await mountController(); + + expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(testState.getToken).not.toHaveBeenCalled(); + expectNoMutations(); + }); + + it.each([AuthRelayReadScope, AuthRelayWriteScope])( + "rechecks %s after requesting the cloud token", + async (scope) => { + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.scopes.delete(scope); + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false); + expectNoMutations(); + }, + ); + + it("stops if the link-state read fails while requesting the cloud token", async () => { + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.snapshot = null; + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false); + expectNoMutations(); + }); + + it("uses the latest linked mode to change publishing without replacing the tunnel", async () => { + testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; + testState.renderedSnapshot = testState.snapshot; + await mountController(); + testState.getToken.mockImplementationOnce(async () => { + testState.snapshot = linkedState; + return "clerk-token"; + }); + + expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.unlink).not.toHaveBeenCalled(); + expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({ + target, + publishAgentActivity: true, + }); + }); + + it("stops the preference write if management permission is lost after linking", async () => { + await mountController(); + testState.link.mockImplementationOnce(async () => { + testState.scopes.delete(AuthRelayWriteScope); + return AsyncResult.success({}); + }); + + expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false); + expect(testState.link).toHaveBeenCalledOnce(); + expect(testState.preferences).not.toHaveBeenCalled(); + expect(testState.refreshLink).toHaveBeenCalledOnce(); + }); + + it("still lets an authorized user unlink when the cloud token is unavailable", async () => { + await mountController(); + testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable")); + + expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true); + expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null }); + expect(testState.link).not.toHaveBeenCalled(); + expect(testState.preferences).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d91596880850..13d8a158a8f5 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -1,5 +1,6 @@ import { useAuth } from "@clerk/react"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts"; import { isAtomCommandInterrupted, settlePromise, @@ -9,13 +10,14 @@ import { useState } from "react"; import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; +import { readEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "./linkEnvironmentAtoms"; -import { usePrimaryCloudLinkState } from "./primaryCloudLinkState"; +import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; export interface CloudLinkDesiredState { @@ -84,7 +86,26 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Local environment is not ready yet.")); return false; } + const readManageableLinkState = () => { + if ( + !readEnvironmentScope(target.environmentId, AuthRelayReadScope) || + !readEnvironmentScope(target.environmentId, AuthRelayWriteScope) + ) { + reportUpdateFailure( + new Error("This connection needs permission to view and manage T3 Connect settings."), + ); + return null; + } + const state = readCachedPrimaryCloudLinkState(target); + if (state === null) { + reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read.")); + } + return state; + }; + if (readManageableLinkState() === null) return false; const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); + const currentLinkState = readManageableLinkState(); + if (currentLinkState === null) return false; const wantsLink = desired.managedTunnel || desired.publish; // A failure after this point may follow a partially applied mutation (e.g. @@ -115,7 +136,8 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); return false; } - if (!linked || managedTunnelActive !== desired.managedTunnel) { + const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked; + if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { const linkResult = await linkPrimaryEnvironment({ target, clerkToken, @@ -129,6 +151,10 @@ export function useCloudLinkController() { return false; } } + if (readManageableLinkState() === null) { + primaryCloudLinkState.refresh(); + return false; + } const prefResult = await updatePrimaryEnvironmentPreferences({ target, publishAgentActivity: desired.publish, diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index a1f31bbf17cf..e4302ce20137 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -1,6 +1,6 @@ import { useAuth } from "@clerk/react"; import { useAtomValue } from "@effect/atom-react"; -import { AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; +import { AuthRelayReadScope, AuthRelayWriteScope, type AuthSessionState } from "@t3tools/contracts"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { CheckIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -71,7 +71,9 @@ function ConfiguredConnectOnboardingDialog() { ? EMPTY_SESSION_STATE_ATOM : environmentSession.sessionStateAtom(primaryEnvironmentId), ); - const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); + const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope); + const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); + const canManageRelay = canReadRelay && canWriteRelay; // The publish step is only offered when we know the answer; opening the // wizard before the session state resolves would let the step set change // mid-flight. A failed session read still opens the wizard — it just means @@ -200,11 +202,13 @@ function ConfiguredConnectOnboardingDialog() { if (isApplying) return; if ( primaryEnvironmentId === null || + !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) || !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) ) { setStep("devices"); return; } + if (linkStateData === null || controller.linkState.error !== null) return; // The wizard only ever enables — with both toggles off there is nothing to // apply, and an existing link must not be torn down from onboarding. if (!exposeEnvironment && !publishAgentActivity) { @@ -253,6 +257,7 @@ function ConfiguredConnectOnboardingDialog() { if ( nextStep === "publish" && (primaryEnvironmentId === null || + !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) || !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)) ) return; @@ -266,11 +271,12 @@ function ConfiguredConnectOnboardingDialog() { { if ( primaryEnvironmentId !== null && + readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) && readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) ) setExposeEnvironment(enabled); @@ -278,6 +284,7 @@ function ConfiguredConnectOnboardingDialog() { onPublishAgentActivityChange={(enabled) => { if ( primaryEnvironmentId !== null && + readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) && readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope) ) setPublishAgentActivity(enabled); @@ -302,11 +309,7 @@ function ConfiguredConnectOnboardingDialog() { Not now - } - /> + }> + + {canMaintainEnvironment ? "Send SIGINT" : "This connection cannot manage processes."} - onSignal(process.pid, "SIGKILL")} - > - KILL - - } - /> + }> + + {canMaintainEnvironment ? "Send SIGKILL" : "This connection cannot manage processes."} From 55cf2152d2e146db3864e9d91af47d53d517f225 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 15:55:04 -0700 Subject: [PATCH 08/39] fix(auth): preserve self-updates across scope migration --- .../src/auth/EnvironmentAuthPolicy.test.ts | 2 + apps/server/src/auth/EnvironmentAuthPolicy.ts | 3 +- .../components/ServerUpdateAction.test.tsx | 86 +++++++++++++++++-- .../web/src/components/ServerUpdateAction.tsx | 32 ++++--- docs/internals/environment-auth.md | 5 ++ packages/contracts/src/auth.ts | 2 + 6 files changed, 110 insertions(+), 20 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts index 982ff397db40..7d2ff33b2249 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.test.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.test.ts @@ -36,6 +36,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("desktop-managed-local"); expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + expect(descriptor.serverUpdateScope).toBe("environment:maintain"); // Packaged desktop has no devUrl, but still needs the port scope: it // scans upward from 3773 for a free port and binds 127.0.0.1, so a second // instance shares this one's hostname on a different port. @@ -90,6 +91,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { expect(descriptor.policy).toBe("loopback-browser"); expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + expect(descriptor.serverUpdateScope).toBe("environment:maintain"); expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/); }).pipe( Effect.provide( diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 446b8a8bba95..5e59aa6cf362 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -1,4 +1,4 @@ -import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { AuthEnvironmentMaintainScope, type ServerAuthDescriptor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -39,6 +39,7 @@ export const make = Effect.gen(function* () { policy, bootstrapMethods, sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], + serverUpdateScope: AuthEnvironmentMaintainScope, sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 612507c7f865..3e1a9e34e944 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,7 +1,8 @@ import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { AuthSessionState, type EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -9,7 +10,8 @@ const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), continueThreadsAfterServerUpdate: false, - canMaintain: true, + session: null as AsyncResult.AsyncResult | null, + sessionAtom: Symbol("session"), })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -21,11 +23,12 @@ 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", () => ({ - useEnvironmentScope: (environmentId: EnvironmentId, scope: string) => - testState.canMaintain && environmentId === "env-test" && scope === "environment:maintain", - readEnvironmentScope: (environmentId: EnvironmentId, scope: string) => - testState.canMaintain && environmentId === "env-test" && scope === "environment:maintain", + environmentSession: { sessionStateAtom: () => testState.sessionAtom }, })); vi.mock("~/state/server", () => ({ serverEnvironment: { updateServer: Symbol("updateServer") }, @@ -39,6 +42,8 @@ vi.mock("./ui/toast", () => ({ import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState); + type ActionElement = ReactElement<{ readonly onClick?: () => void; }>; @@ -57,17 +62,82 @@ async function flushPromises(): Promise { await Promise.resolve(); } +const legacyAuth = { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", +} as const; + +const currentSession = { + authenticated: true, + scopes: ["environment:maintain"], + auth: { + ...legacyAuth, + serverUpdateScope: "environment:maintain", + }, +} as const satisfies AuthSessionState; + describe("ServerUpdateAction", () => { beforeEach(() => { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.continueThreadsAfterServerUpdate = false; - testState.canMaintain = true; + 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.canMaintain = false; + testState.session = AsyncResult.success({ ...currentSession, scopes: [] }); action.props.onClick?.(); await flushPromises(); expect(testState.updateServer).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 3c339358b132..d49b8c0078bb 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -1,5 +1,7 @@ -import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; -import { useEnvironmentScope, readEnvironmentScope } from "~/state/session"; +import { useAtomValue } from "@effect/atom-react"; +import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts"; +import type { AsyncResult } from "effect/unstable/reactivity"; +import { environmentSession } from "~/state/session"; import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { @@ -12,6 +14,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; @@ -36,6 +39,17 @@ function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } +function canUpdateServer(result: AsyncResult.AsyncResult): boolean { + if (result._tag !== "Success" || !result.value.authenticated) return false; + const session = result.value; + // Only self-update bridges the old authorization protocol. Upgraded servers + // advertise the new scope even when this client's grant predates it. + return ( + session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) === + true + ); +} + /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot @@ -101,7 +115,8 @@ export function ServerUpdateAction({ readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; - const canMaintain = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); + const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); + const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, @@ -129,7 +144,7 @@ export function ServerUpdateAction({ const handleUpdate = async () => { if ( - !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || + !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || pendingUpdateEnvironmentIds.has(environmentId) ) { return; @@ -147,7 +162,7 @@ export function ServerUpdateAction({ } } if ( - !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) || + !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || pendingUpdateEnvironmentIds.has(environmentId) ) { return; @@ -204,12 +219,7 @@ export function ServerUpdateAction({ } return ( - ); diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index dc43b4d45c03..8cb65ad6d69a 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -30,6 +30,11 @@ authenticate the upgrade with their cookie. A successful handshake grants no extra authority: [every RPC declares a required scope](../../apps/server/src/auth/RpcAuthorization.ts). +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/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 51ff6e648431..4d0e2eece11c 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -154,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; From 3d5610b17d17e5218289f1ffb5c7f982e53ab46a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 16:46:07 -0700 Subject: [PATCH 09/39] fix(web): preserve untouched script shortcuts on save --- apps/web/src/components/ChatView.tsx | 2 +- .../components/projectScriptEditor.test.tsx | 142 ++++++++++++++++++ .../src/components/projectScriptEditor.tsx | 12 +- 3 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/projectScriptEditor.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fe7131b0a154..8c70f492da30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3532,7 +3532,7 @@ export default function ChatView(props: ChatViewProps) { projectCwd: string; previousScripts: ReadonlyArray; nextScripts: ReadonlyArray; - keybinding?: string | null; + keybinding: NewProjectScriptInput["keybinding"]; keybindingCommand: KeybindingCommand; }): Promise> => { const previousKeybinding = keybindingValueForCommand( diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx new file mode 100644 index 000000000000..34d3c2af569d --- /dev/null +++ b/apps/web/src/components/projectScriptEditor.test.tsx @@ -0,0 +1,142 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const permissions = vi.hoisted(() => ({ canWriteSettings: false })); + +vi.mock("~/state/session", () => ({ + readEnvironmentScope: () => permissions.canWriteSettings, + useEnvironmentScope: () => permissions.canWriteSettings, +})); +vi.mock("./ui/dialog", () => ({ + Dialog: "div", + DialogDescription: "div", + DialogFooter: "div", + DialogHeader: "div", + DialogPanel: "div", + DialogPopup: "div", + DialogTitle: "div", +})); +vi.mock("./ui/alert-dialog", () => ({ + AlertDialog: "div", + AlertDialogClose: "button", + AlertDialogDescription: "div", + AlertDialogFooter: "div", + AlertDialogHeader: "div", + AlertDialogPopup: "div", + AlertDialogTitle: "div", +})); +vi.mock("./ui/popover", () => ({ + Popover: "div", + PopoverPopup: "div", + PopoverTrigger: "button", +})); +vi.mock("./ui/button", () => ({ Button: "button" })); +vi.mock("./ui/input", () => ({ Input: "input" })); +vi.mock("./ui/label", () => ({ Label: "label" })); +vi.mock("./ui/switch", () => ({ Switch: "input" })); +vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); + +import { + ProjectScriptEditorDialog, + type NewProjectScriptInput, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; + +const request: ProjectScriptEditorRequest = { + scriptId: "test", + initial: { + name: "Test", + command: "vp test", + icon: "test", + runOnWorktreeCreate: false, + keybinding: "mod+k", + previewUrl: null, + autoOpenPreview: false, + }, +}; + +describe("ProjectScriptEditorDialog", () => { + let renderer: ReactTestRenderer | undefined; + + beforeEach(() => { + permissions.canWriteSettings = false; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + }); + + afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + }); + + async function openEditor(onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined))) { + await act(() => { + renderer = create( + , + ); + }); + return renderer!.root; + } + + it.each([false, true])( + "preserves a concurrent shortcut change when saving only the script (settings access: %s)", + async (canWriteSettings) => { + permissions.canWriteSettings = canWriteSettings; + const persisted = { name: request.initial.name, keybinding: request.initial.keybinding }; + const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { + persisted.name = input.name; + if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; + return AsyncResult.success(undefined); + }); + const root = await openEditor(onSubmit); + + // A second client changes the shortcut after this dialog snapshots it. + persisted.keybinding = "mod+j"; + await act(() => { + root.findByProps({ id: "script-name" }).props.onChange({ target: { value: "Renamed" } }); + }); + await act(async () => { + await root.findByType("form").props.onSubmit({ preventDefault() {} }); + }); + + expect(persisted).toEqual({ name: "Renamed", keybinding: "mod+j" }); + expect(onSubmit).toHaveBeenCalledOnce(); + }, + ); + + it.each([false, true])( + "enforces settings access when submitting an explicit shortcut clear (revoked: %s)", + async (revokeBeforeSubmit) => { + permissions.canWriteSettings = true; + const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); + const root = await openEditor(onSubmit); + await act(() => { + root.findByProps({ id: "script-keybinding" }).props.onKeyDown({ + key: "Backspace", + preventDefault() {}, + }); + }); + if (revokeBeforeSubmit) permissions.canWriteSettings = false; + await act(async () => { + await root.findByType("form").props.onSubmit({ preventDefault() {} }); + }); + + if (revokeBeforeSubmit) { + expect(onSubmit).not.toHaveBeenCalled(); + } else { + expect(onSubmit).toHaveBeenCalledWith( + "test", + expect.objectContaining({ keybinding: null }), + ); + } + }, + ); +}); diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 17e0a24f477c..34ce366865e8 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -81,7 +81,8 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; - keybinding: string | null; + /** Omit to preserve the current shortcut when the form did not edit it. */ + keybinding?: string | null; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; /** When true, automatically open the preview panel pointed at `previewUrl`. */ @@ -196,10 +197,9 @@ export function ProjectScriptEditorDialog({ const submit = async (event: FormEvent) => { event.preventDefault(); if (!request) return; - if ( - (keybinding.trim() || null) !== request.initial.keybinding && - !readEnvironmentScope(environmentId, AuthSettingsWriteScope) - ) { + const changesKeybinding = + (keybinding.trim() || null) !== (request.initial.keybinding?.trim() || null); + if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { setValidationError("This connection cannot change keyboard shortcuts."); return; } @@ -233,7 +233,7 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, - keybinding: keybindingRule?.key ?? null, + ...(changesKeybinding ? { keybinding: keybindingRule?.key ?? null } : {}), previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; From d1809b2dfb403b074846299797e507a27b82ceba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:01:20 -0700 Subject: [PATCH 10/39] fix(web): initialize shortcuts when adding scripts --- .../components/projectScriptEditor.test.tsx | 27 +++++++++++++++++-- .../src/components/projectScriptEditor.tsx | 4 ++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx index 34d3c2af569d..d62e30803145 100644 --- a/apps/web/src/components/projectScriptEditor.test.tsx +++ b/apps/web/src/components/projectScriptEditor.test.tsx @@ -70,12 +70,15 @@ describe("ProjectScriptEditorDialog", () => { vi.unstubAllGlobals(); }); - async function openEditor(onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined))) { + async function openEditor( + onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)), + editorRequest = request, + ) { await act(() => { renderer = create( { return renderer!.root; } + it("clears an old shortcut when adding an action with no shortcut", async () => { + permissions.canWriteSettings = true; + let persistedKeybinding: string | null = "mod+k"; + const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { + if (input.keybinding !== undefined) persistedKeybinding = input.keybinding; + return AsyncResult.success(undefined); + }); + const root = await openEditor(onSubmit, { + scriptId: null, + initial: { ...request.initial, keybinding: null }, + }); + + await act(async () => { + await root.findByType("form").props.onSubmit({ preventDefault() {} }); + }); + + expect(persistedKeybinding).toBeNull(); + expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null })); + }); + it.each([false, true])( "preserves a concurrent shortcut change when saving only the script (settings access: %s)", async (canWriteSettings) => { diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 34ce366865e8..f2d894eb48f3 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -233,7 +233,9 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, - ...(changesKeybinding ? { keybinding: keybindingRule?.key ?? null } : {}), + ...(request.scriptId === null || changesKeybinding + ? { keybinding: keybindingRule?.key ?? null } + : {}), previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, } satisfies NewProjectScriptInput; From 97fc24a2b1120e47154bd7ba353257d542a6bf86 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:15:10 -0700 Subject: [PATCH 11/39] fix(web): allow relay unlink after link-state failure --- .../src/cloud/useCloudLinkController.test.tsx | 14 ++++++++++++++ apps/web/src/cloud/useCloudLinkController.ts | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx index a44e2b59fec7..b06cdfd2c04e 100644 --- a/apps/web/src/cloud/useCloudLinkController.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -213,4 +213,18 @@ describe("useCloudLinkController", () => { 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 13d8a158a8f5..e8fdb6987a25 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -86,7 +86,7 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Local environment is not ready yet.")); return false; } - const readManageableLinkState = () => { + const canManageLink = () => { if ( !readEnvironmentScope(target.environmentId, AuthRelayReadScope) || !readEnvironmentScope(target.environmentId, AuthRelayWriteScope) @@ -94,19 +94,22 @@ export function useCloudLinkController() { reportUpdateFailure( new Error("This connection needs permission to view and manage T3 Connect settings."), ); - return null; + 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 (readManageableLinkState() === null) return false; - const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions())); - const currentLinkState = readManageableLinkState(); - if (currentLinkState === null) return false; + 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 — @@ -127,6 +130,8 @@ export function useCloudLinkController() { return false; } } else { + const currentLinkState = readLinkState(); + if (currentLinkState === null) return false; if (tokenResult._tag === "Failure") { reportUpdateFailure(squashAtomCommandFailure(tokenResult)); return false; @@ -151,7 +156,7 @@ export function useCloudLinkController() { return false; } } - if (readManageableLinkState() === null) { + if (!canManageLink() || readLinkState() === null) { primaryCloudLinkState.refresh(); return false; } From c7c40a048a07fdc7b412b42be3b345bce08b8101 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:17:31 -0700 Subject: [PATCH 12/39] fix(web): sync shared settings before remote grants load --- apps/web/src/hooks/useSettings.sync.test.tsx | 177 +++++++++++++++++++ apps/web/src/hooks/useSettings.ts | 22 ++- 2 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/hooks/useSettings.sync.test.tsx diff --git a/apps/web/src/hooks/useSettings.sync.test.tsx b/apps/web/src/hooks/useSettings.sync.test.tsx new file mode 100644 index 000000000000..abd0151ac562 --- /dev/null +++ b/apps/web/src/hooks/useSettings.sync.test.tsx @@ -0,0 +1,177 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + AuthSettingsWriteScope, + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type AuthEnvironmentScope, + type AuthSessionState, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type SessionResult = AsyncResult.AsyncResult; +const state = vi.hoisted(() => ({ + registry: null as AtomRegistry.AtomRegistry | null, + sessions: new Map>(), + environments: [] as Array<{ + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + serverConfig: { + environment: { capabilities: { threadAutoSettlement: boolean } }; + settings: typeof DEFAULT_SERVER_SETTINGS; + }; + }>, + persist: vi.fn(), +})); + +vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined })); +vi.mock("@t3tools/client-runtime/state/session", () => ({ + createEnvironmentSessionAtoms: () => ({ + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + }), +})); +vi.mock("~/rpc/atomRegistry", () => ({ + get appAtomRegistry() { + return state.registry; + }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: state.environments }), + usePrimaryEnvironment: () => state.environments[0], +})); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateSettings: Symbol("updateSettings") }, + primaryServerSettingsAtom: undefined, +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist })); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); +vi.mock("~/themePalette", () => ({})); +vi.mock("./useTheme", () => ({})); + +import { useUpdatePrimarySettings } from "./useSettings"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const patch = { sidebarAutoSettleOnMerge: false } satisfies ServerSettingsPatch; +const session = (scopes: ReadonlyArray): AuthSessionState => ({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, +}); +let renderer: ReactTestRenderer | undefined; + +function SettingsEditor() { + const updateSettings = useUpdatePrimarySettings(); + return ; +} + +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)); +}); + +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("rechecks a cold remote grant that resolves to denied after the handler renders", async () => { + 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(); + + expect(state.persist).toHaveBeenCalledExactlyOnceWith({ + environmentId: primaryId, + input: { patch }, + }); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 68083d7957c6..43260d68669a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -52,6 +52,7 @@ 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]"; @@ -416,15 +417,19 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } -/** Environments that can receive a shared settings write right now. */ -function useSharedSettingsSyncTargetIds(): ReadonlyArray { +/** 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) => { - // Subscribe before offering sync so the first action sees each target's grant. 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) @@ -432,7 +437,7 @@ function useSharedSettingsSyncTargetIds(): ReadonlyArray { : []; }), ), - [environments], + [environments, includePending], ); return useAtomValue(writableTargetsAtom); } @@ -454,6 +459,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { "server settings update", ); const { environments } = useEnvironments(); + const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -490,9 +496,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set( - environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), - ); + const targets = new Set(sharedSettingsSyncTargetIds); if (environmentId) { targets.add(environmentId); } @@ -504,7 +508,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { target?.serverConfig?.environment.capabilities, ); 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), ) @@ -527,7 +533,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { persistClientSettingsPatch(clientPatch); } }, - [environmentId, environments, persistServerSettings], + [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds], ); return updateSettings; From 80987e503d1261f19fb9f988ad306a2f8b4673f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:23:40 -0700 Subject: [PATCH 13/39] test(web): type failed shared settings grants --- apps/web/src/hooks/useSettings.sync.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useSettings.sync.test.tsx b/apps/web/src/hooks/useSettings.sync.test.tsx index abd0151ac562..53ae63a88c47 100644 --- a/apps/web/src/hooks/useSettings.sync.test.tsx +++ b/apps/web/src/hooks/useSettings.sync.test.tsx @@ -133,7 +133,10 @@ describe("shared settings writes", () => { it.each([ ["denied", () => AsyncResult.success(session([]))], ["denied while refreshing", () => AsyncResult.waiting(AsyncResult.success(session([])))], - ["failed", () => AsyncResult.failure(Cause.fail(new Error("session rejected")))], + [ + "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(); From b9e37df803667cd2cacd92c6cce14299e74f5cfa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 20:32:50 -0700 Subject: [PATCH 14/39] fix(web): resolve script shortcuts in the active environment --- apps/web/src/components/ChatView.tsx | 9 ++- apps/web/src/keybindings.test.ts | 97 ++++++++++++++++++++++++++++ apps/web/src/keybindings.ts | 19 ++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8c70f492da30..7e8a672a3dbd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -185,7 +185,8 @@ import { foldSubagentActivities, } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar } from "./BranchToolbar"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -3021,6 +3022,9 @@ export default function ChatView(props: ChatViewProps) { resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`, }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const scriptKeybindings = + useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? + DEFAULT_RESOLVED_KEYBINDINGS; const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); const manualCompactionProviderAvailable = useMemo( () => @@ -5846,7 +5850,7 @@ export default function ChatView(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { + const command = resolveChatShortcutCommand(event, keybindings, scriptKeybindings, { context: shortcutContext, }); if (!command) return; @@ -6030,6 +6034,7 @@ export default function ChatView(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + scriptKeybindings, handleUnsettleActiveThread, isServerThread, onToggleDiff, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index df005571193e..98850c0e76b3 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -18,6 +18,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + resolveChatShortcutCommand, resolveShortcutCommand, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, @@ -737,6 +738,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("returns dynamic script commands", () => { const keybindings = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 1b4fa072d5a1..e028fda79bbe 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -9,6 +9,7 @@ import { type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; +import { projectScriptIdFromCommand } from "./projectScripts"; export interface ShortcutEventLike { type?: string; @@ -222,6 +223,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; +} + function formatShortcutKeyLabel(key: string): string { if (key === " ") return "Space"; if (key.length === 1) return key.toUpperCase(); From 218cb65e4347ac2a19233ff4c91ab03dc79f04e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 21:14:19 -0700 Subject: [PATCH 15/39] fix(server): choose enabled instances for text generation --- apps/server/src/serverSettings.test.ts | 83 ++++++++++++++++++++++++++ apps/server/src/serverSettings.ts | 18 ++++-- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6e837308e8cb..764eb63ad8ce 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -424,6 +424,89 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + for (const fallbackId of ["codex", "codex_work"]) { + it.effect(`falls back to enabled instance ${fallbackId} after disabling the selection`, () => + Effect.scoped( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const writerId = ProviderInstanceId.make("writer"); + const fallbackInstanceId = ProviderInstanceId.make(fallbackId); + const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; + const providerInstances = { + [fallbackInstanceId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + [writerId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: {}, + }, + }; + + yield* serverSettings.updateSettings({ + providers: Object.fromEntries( + Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( + (provider) => [provider, { enabled: false }] as const, + ), + ), + providerInstances, + textGenerationModelSelection: selection, + }); + const changes = yield* serverSettings.subscribeChanges; + + const next = yield* serverSettings.updateSettings({ + providerInstances: { + ...providerInstances, + [writerId]: { ...providerInstances[writerId]!, enabled: false }, + }, + }); + const fallbackSelection = { + instanceId: fallbackInstanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }; + assert.deepEqual(next.textGenerationModelSelection, fallbackSelection); + assert.deepEqual( + (yield* serverSettings.getSettings).textGenerationModelSelection, + fallbackSelection, + ); + const change = Option.getOrUndefined(yield* Stream.runHead(changes)); + assert.deepEqual(change?.textGenerationModelSelection, fallbackSelection); + + const persisted = yield* fileSystem + .readFileString(serverConfig.settingsPath) + .pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))), + ); + assert.deepEqual(persisted.textGenerationModelSelection, selection); + + const restored = yield* serverSettings.updateSettings({ providerInstances }); + assert.deepEqual(restored.textGenerationModelSelection, selection); + }), + ).pipe(Effect.provide(makeServerSettingsLayer())), + ); + } + + it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const next = yield* serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + }, + }); + + assert.equal(next.textGenerationModelSelection.instanceId, "claudeAgent"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5f2550534883..9e72cd2b205e 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -21,6 +21,7 @@ import { type UsageLimitSourceConfig, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -321,16 +322,25 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + const legacyInstances = Object.fromEntries( + Object.entries(settings.providers).map(([driver, config]) => [ + driver, + { driver: ProviderDriverKind.make(driver), config }, + ]), + ); + const fallbackEntry = Object.entries({ + ...legacyInstances, + ...settings.providerInstances, + }).find(([, instance]) => resolveProviderInstanceEnabled(instance)); + if (!fallbackEntry) { return settings; } + const [instanceId, { driver: fallback }] = fallbackEntry; return { ...settings, textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), + instanceId: ProviderInstanceId.make(instanceId), model: DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback] ?? From d950aaab992c3fc5380047f4de076352370556f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 21:17:24 -0700 Subject: [PATCH 16/39] fix(web): preserve retained shortcuts when re-adding actions --- .../src/components/ProjectScriptsControl.tsx | 10 ++- .../components/projectScriptEditor.test.tsx | 69 +++++++++++++++++++ .../src/components/projectScriptEditor.tsx | 5 +- .../settings/ProjectSettingsPanel.tsx | 6 +- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index cb6ac79a3056..8f55d74e95fa 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId, ProjectScript, T3ProjectFileScript } from "@t3tools/contracts"; +import { + AuthSettingsWriteScope, + type EnvironmentId, + type ProjectScript, + type T3ProjectFileScript, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { @@ -11,6 +16,7 @@ import { useCallback, useMemo, useState } from "react"; import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { serverEnvironment } from "~/state/server"; +import { readEnvironmentScope } from "~/state/session"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -115,7 +121,7 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, - keybinding: null, + ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}), previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, }; diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx index d62e30803145..154dd8cc4520 100644 --- a/apps/web/src/components/projectScriptEditor.test.tsx +++ b/apps/web/src/components/projectScriptEditor.test.tsx @@ -1,4 +1,5 @@ import { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { act, create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -109,6 +110,74 @@ describe("ProjectScriptEditorDialog", () => { expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null })); }); + it.each([false, true])( + "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)", + async (revokeBeforeSubmit) => { + permissions.canWriteSettings = revokeBeforeSubmit; + const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; + const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { + if (input.keybinding !== undefined && !permissions.canWriteSettings) { + return AsyncResult.failure( + Cause.fail(new Error("This connection cannot change keyboard shortcuts.")), + ); + } + persisted.name = input.name; + if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; + return AsyncResult.success(undefined); + }); + const root = await openEditor(onSubmit, { + scriptId: null, + initial: { ...request.initial, keybinding: null }, + }); + + permissions.canWriteSettings = false; + await act(async () => { + await root.findByType("form").props.onSubmit({ preventDefault() {} }); + }); + + expect(persisted).toEqual({ name: "Test", keybinding: "mod+k" }); + expect(onSubmit).toHaveBeenCalledOnce(); + }, + ); + + it.each([false, true])( + "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)", + async (revokeBeforeSubmit) => { + permissions.canWriteSettings = true; + vi.stubGlobal("navigator", { platform: "Linux" }); + const persisted = { name: null as string | null, keybinding: "mod+k" as string | null }; + const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => { + persisted.name = input.name; + if (input.keybinding !== undefined) persisted.keybinding = input.keybinding; + return AsyncResult.success(undefined); + }); + const root = await openEditor(onSubmit, { + scriptId: null, + initial: { ...request.initial, keybinding: null }, + }); + await act(() => { + root.findByProps({ id: "script-keybinding" }).props.onKeyDown({ + key: "j", + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + preventDefault() {}, + }); + }); + if (revokeBeforeSubmit) permissions.canWriteSettings = false; + await act(async () => { + await root.findByType("form").props.onSubmit({ preventDefault() {} }); + }); + + expect(persisted).toEqual( + revokeBeforeSubmit + ? { name: null, keybinding: "mod+k" } + : { name: "Test", keybinding: "mod+j" }, + ); + }, + ); + it.each([false, true])( "preserves a concurrent shortcut change when saving only the script (settings access: %s)", async (canWriteSettings) => { diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index f2d894eb48f3..68613fe64f66 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -199,7 +199,8 @@ export function ProjectScriptEditorDialog({ if (!request) return; const changesKeybinding = (keybinding.trim() || null) !== (request.initial.keybinding?.trim() || null); - if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + const canChangeKeybinding = readEnvironmentScope(environmentId, AuthSettingsWriteScope); + if (changesKeybinding && !canChangeKeybinding) { setValidationError("This connection cannot change keyboard shortcuts."); return; } @@ -233,7 +234,7 @@ export function ProjectScriptEditorDialog({ command: trimmedCommand, icon, runOnWorktreeCreate, - ...(request.scriptId === null || changesKeybinding + ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding ? { keybinding: keybindingRule?.key ?? null } : {}), previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 3ebb95987bd6..1f56e74e63fd 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -710,7 +710,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, - keybinding: null, + ...(readEnvironmentScope(selectedCheckout.environmentId, AuthSettingsWriteScope) + ? { keybinding: null } + : {}), previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, }; @@ -724,7 +726,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [selectedCheckout.environmentId, submitScript], ); // ----- checkouts ----- From 3b55f5ad0b76ae0376830928d728fa49e272111b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 21:50:20 -0700 Subject: [PATCH 17/39] fix(server): preserve provider fallback tuple types --- apps/server/src/serverSettings.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 9e72cd2b205e..8529198614e2 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -323,10 +323,9 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { const legacyInstances = Object.fromEntries( - Object.entries(settings.providers).map(([driver, config]) => [ - driver, - { driver: ProviderDriverKind.make(driver), config }, - ]), + Object.entries(settings.providers).map( + ([driver, config]) => [driver, { driver: ProviderDriverKind.make(driver), config }] as const, + ), ); const fallbackEntry = Object.entries({ ...legacyInstances, From 0d80cc45f78c860a2dab65d38a988214a402863a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:11:42 -0700 Subject: [PATCH 18/39] fix(server): keep effective provider fallback entries typed --- apps/server/src/serverSettings.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 8529198614e2..44d922d4bc10 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -322,15 +322,14 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const legacyInstances = Object.fromEntries( - Object.entries(settings.providers).map( - ([driver, config]) => [driver, { driver: ProviderDriverKind.make(driver), config }] as const, - ), + const providerInstances: Record = {}; + for (const [driver, config] of Object.entries(settings.providers)) { + providerInstances[driver] = { driver: ProviderDriverKind.make(driver), config }; + } + Object.assign(providerInstances, settings.providerInstances); + const fallbackEntry = Object.entries(providerInstances).find(([, instance]) => + resolveProviderInstanceEnabled(instance), ); - const fallbackEntry = Object.entries({ - ...legacyInstances, - ...settings.providerInstances, - }).find(([, instance]) => resolveProviderInstanceEnabled(instance)); if (!fallbackEntry) { return settings; } From eb952846176a40ee91a3e167f726ccc6e1a0d65c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:12:44 -0700 Subject: [PATCH 19/39] fix(web): update settings controls when grants change --- ...roviderInstanceDialog.environment.test.tsx | 96 +++++++++++++- .../settings/AddProviderInstanceDialog.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 18 +-- .../KeybindingsSettings.environment.test.tsx | 123 ++++++++++++++++++ .../settings/KeybindingsSettings.tsx | 16 ++- ...ProviderSettingsPanel.environment.test.tsx | 19 +++ .../settings/ProviderSettingsPanel.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 14 +- 8 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/settings/KeybindingsSettings.environment.test.tsx diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 3c502c624ddd..4b635afeed4b 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -1,11 +1,19 @@ import { EnvironmentId } from "@t3tools/contracts"; 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: {} })), - update: vi.fn(() => vi.fn()), + update: vi.fn(() => actions.update), })); vi.mock("react", async (importOriginal) => { @@ -28,15 +36,61 @@ vi.mock("../../hooks/useSettings", () => ({ useUpdateEnvironmentSettings: settingsHooks.update, })); +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"); +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, "Next").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(); settingsHooks.read.mockClear(); settingsHooks.update.mockClear(); + actions.update.mockReset(); + actions.toast.mockReset(); + actions.onOpenChange.mockReset(); + actions.canManageProviders = true; }); it("reads and writes settings through the supplied environment", () => { @@ -51,4 +105,44 @@ describe("AddProviderInstanceDialog environment routing", () => { expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); }); + + it("adds an instance with the selected environment's provider grant alone", () => { + const dialog = prepareInstance(); + (button(dialog, "Add instance").props.onClick as () => void)(); + + expect(actions.update).toHaveBeenCalledWith({ + providerInstances: { codex_work: { driver: "codex", enabled: true, displayName: "Work" } }, + }); + 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(actions.update).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", () => { + actions.canManageProviders = false; + let dialog = prepareInstance(); + (button(dialog, "Add instance").props.onClick as () => void)(); + expect(actions.update).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)(); + expect(actions.update).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 9671755f8bea..81a9196cdcbc 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -4,6 +4,7 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { + AuthProvidersManageScope, ProviderInstanceId, ProviderDriverKind, type EnvironmentId, @@ -13,6 +14,7 @@ import { import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; import { @@ -130,6 +132,7 @@ export function AddProviderInstanceDialog({ }: AddProviderInstanceDialogProps) { const settings = useEnvironmentSettings(environmentId); const updateSettings = useUpdateEnvironmentSettings(environmentId); + const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -188,6 +191,7 @@ export function AddProviderInstanceDialog({ }; const handleSave = () => { + if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; setHasAttemptedSubmit(true); if (instanceIdError !== null) return; @@ -430,7 +434,9 @@ export function AddProviderInstanceDialog({ {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? ( ) : ( - + )}
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index cd25a8482109..be9d63f17642 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3390,7 +3390,7 @@ export function ConnectionsSettings() { ) : null} { if (isUpdatingDesktopServerExposure) return; setIsDesktopServerExposureDialogOpen(open); @@ -3425,7 +3425,9 @@ export function ConnectionsSettings() { } onClick={handleConfirmDesktopServerExposureChange} disabled={ - pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure + !canManageLocalBackend || + pendingDesktopServerExposureMode === null || + isUpdatingDesktopServerExposure } > {isUpdatingDesktopServerExposure ? ( @@ -3443,7 +3445,7 @@ export function ConnectionsSettings() { { if (isUpdatingWslBackend) return; if (!open) setPendingWslChange(null); @@ -3490,7 +3492,7 @@ export function ConnectionsSettings() {