authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:11-07:00
log28ce9d1c1751d70c869e3de3287b96d5e28fc845
tree815ff97b068e373f6c6ff5705ed5a35f0f253a20
parente9c3d950f023130791d8ac5fe7aa0992e3430738
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate environment administration permissions (#9786)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

162 files changed, 8378 insertions(+), 1114 deletions(-)

AGENTS.md+1
...@@ -150,6 +150,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/...@@ -150,6 +150,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/
150## Taste150## Taste
151151
152- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.152- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.
153- Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method.
153- Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md).154- Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md).
154- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md).155- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md).
155- Inferred types over annotations. `any` is the enemy.156- Inferred types over annotations. `any` is the enemy.
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+114-1
...@@ -25,7 +25,14 @@ vi.mock("react-native", () => ({...@@ -25,7 +25,14 @@ vi.mock("react-native", () => ({
25 Platform: { OS: "ios" },25 Platform: { OS: "ios" },
26}));26}));
2727
28vi.mock("../cloud/linkEnvironment", () => ({28vi.mock("expo-constants", () => ({
29 default: { expoConfig: {} },
30}));
31
32vi.mock("expo-device", () => ({}));
33
34vi.mock("../cloud/linkEnvironment", async (importOriginal) => ({
35 ...(await importOriginal<typeof import("../cloud/linkEnvironment")>()),
29 linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),36 linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),
30}));37}));
3138
...@@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => {...@@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => {
78 previousEnabled: true,85 previousEnabled: true,
79 clerkToken: "clerk-token",86 clerkToken: "clerk-token",
80 connections: [connection],87 connections: [connection],
88 canConfigureEnvironment: () => true,
81 });89 });
8290
83 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({91 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
...@@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => {...@@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => {
98 previousEnabled: false,106 previousEnabled: false,
99 clerkToken: "clerk-token",107 clerkToken: "clerk-token",
100 connections: [connection],108 connections: [connection],
109 canConfigureEnvironment: () => true,
101 });110 });
102111
103 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({112 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
...@@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => {...@@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => {
118 previousEnabled: true,127 previousEnabled: true,
119 clerkToken: null,128 clerkToken: null,
120 connections: [connection],129 connections: [connection],
130 canConfigureEnvironment: () => true,
121 });131 });
122132
123 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({133 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
...@@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => {...@@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => {
139 previousEnabled: false,149 previousEnabled: false,
140 clerkToken: "clerk-token",150 clerkToken: "clerk-token",
141 connections: [connection, managedConnection],151 connections: [connection, managedConnection],
152 canConfigureEnvironment: () => true,
142 });153 });
143154
144 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);155 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
...@@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => {...@@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => {
162 previousEnabled: true,173 previousEnabled: true,
163 clerkToken: "clerk-token",174 clerkToken: "clerk-token",
164 connections: [connection],175 connections: [connection],
176 canConfigureEnvironment: () => true,
165 }),177 }),
166 );178 );
167179
...@@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => {...@@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => {
184 });196 });
185 }).pipe(Effect.provide(layerTest));197 }).pipe(Effect.provide(layerTest));
186 });198 });
199
200 it.effect("only re-links environments with relay access when enabling updates", () => {
201 const readOnlyConnection: SavedRemoteConnection = {
202 ...connection,
203 environmentId: "read-only" as EnvironmentId,
204 };
205
206 return Effect.gen(function* () {
207 yield* setLiveActivityUpdatesEnabled({
208 enabled: true,
209 previousEnabled: false,
210 clerkToken: "clerk-token",
211 connections: [readOnlyConnection, connection],
212 canConfigureEnvironment: (environmentId) => environmentId === connection.environmentId,
213 });
214
215 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
216 liveActivitiesEnabled: true,
217 });
218 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledExactlyOnceWith({
219 clerkToken: "clerk-token",
220 connection,
221 liveActivitiesEnabled: true,
222 });
223 }).pipe(Effect.provide(layerTest));
224 });
225
226 it.effect("keeps device updates independently switchable without relay access", () =>
227 Effect.gen(function* () {
228 for (const enabled of [false, true]) {
229 yield* setLiveActivityUpdatesEnabled({
230 enabled,
231 previousEnabled: !enabled,
232 clerkToken: "clerk-token",
233 connections: [connection],
234 canConfigureEnvironment: () => false,
235 });
236 }
237
238 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, {
239 liveActivitiesEnabled: false,
240 });
241 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
242 liveActivitiesEnabled: true,
243 });
244 expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
245 }).pipe(Effect.provide(layerTest)),
246 );
247
248 it.effect("checks current relay access after updating the device registration", () => {
249 let canConfigureEnvironment = true;
250 vi.mocked(updateAgentAwarenessRegistrationPreferences).mockImplementationOnce(() =>
251 Effect.sync(() => {
252 canConfigureEnvironment = false;
253 }),
254 );
255
256 return Effect.gen(function* () {
257 yield* setLiveActivityUpdatesEnabled({
258 enabled: false,
259 previousEnabled: true,
260 clerkToken: "clerk-token",
261 connections: [connection],
262 canConfigureEnvironment: () => canConfigureEnvironment,
263 });
264
265 expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
266 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledTimes(1);
267 }).pipe(Effect.provide(layerTest));
268 });
269
270 it.effect("does not retry environment changes after relay access is revoked", () => {
271 let canConfigureEnvironment = true;
272 vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() =>
273 Effect.sync(() => {
274 canConfigureEnvironment = false;
275 }).pipe(
276 Effect.andThen(
277 Effect.fail(new CloudEnvironmentLinkError({ message: "relay access revoked" })),
278 ),
279 ),
280 );
281
282 return Effect.gen(function* () {
283 const exit = yield* Effect.exit(
284 setLiveActivityUpdatesEnabled({
285 enabled: false,
286 previousEnabled: true,
287 clerkToken: "clerk-token",
288 connections: [connection],
289 canConfigureEnvironment: () => canConfigureEnvironment,
290 }),
291 );
292
293 expect(exit._tag).toBe("Failure");
294 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
295 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
296 liveActivitiesEnabled: true,
297 });
298 }).pipe(Effect.provide(layerTest));
299 });
187});300});
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+18-11
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import type { EnvironmentId } from "@t3tools/contracts";
23
3import type { SavedRemoteConnection } from "../../lib/connection";4import type { SavedRemoteConnection } from "../../lib/connection";
4import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";5import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
...@@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn...@@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
10 readonly previousEnabled: boolean;11 readonly previousEnabled: boolean;
11 readonly clerkToken: string | null;12 readonly clerkToken: string | null;
12 readonly connections: ReadonlyArray<SavedRemoteConnection>;13 readonly connections: ReadonlyArray<SavedRemoteConnection>;
14 readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean;
13 }) {15 }) {
14 const linkedConnections = input.connections.filter(16 const linkedConnections = input.connections.filter(
15 (connection) => connection.bearerToken !== null,17 (connection) => connection.bearerToken !== null,
16 );18 );
1719
20 const updateEnvironmentPreference = Effect.fn("updateEnvironmentPreference")(function* (
21 connection: SavedRemoteConnection,
22 enabled: boolean,
23 clerkToken: string,
24 ) {
25 if (!input.canConfigureEnvironment(connection.environmentId)) return;
26
27 yield* linkEnvironmentToCloudWithPreference({
28 clerkToken,
29 connection,
30 liveActivitiesEnabled: enabled,
31 });
32 });
33
18 const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {34 const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {
19 yield* updateAgentAwarenessRegistrationPreferences({35 yield* updateAgentAwarenessRegistrationPreferences({
20 liveActivitiesEnabled: enabled,36 liveActivitiesEnabled: enabled,
...@@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn...@@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
2541
26 yield* Effect.forEach(42 yield* Effect.forEach(
27 linkedConnections,43 linkedConnections,
28 (connection) =>44 (connection) => updateEnvironmentPreference(connection, enabled, clerkToken),
29 linkEnvironmentToCloudWithPreference({
30 clerkToken,
31 connection,
32 liveActivitiesEnabled: enabled,
33 }),
34 { concurrency: "unbounded" },45 { concurrency: "unbounded" },
35 );46 );
36 });47 });
...@@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn...@@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
50 yield* Effect.forEach(61 yield* Effect.forEach(
51 linkedConnections,62 linkedConnections,
52 (connection) =>63 (connection) =>
53 linkEnvironmentToCloudWithPreference({64 updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe(
54 clerkToken,
55 connection,
56 liveActivitiesEnabled: input.previousEnabled,
57 }).pipe(
58 Effect.catchCause((cause) =>65 Effect.catchCause((cause) =>
59 Effect.logWarning(66 Effect.logWarning(
60 `Could not restore Live Activity preference for environment ${connection.environmentId}.`,67 `Could not restore Live Activity preference for environment ${connection.environmentId}.`,
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+62-53
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4} from "@t3tools/client-runtime/state/shell";4} from "@t3tools/client-runtime/state/shell";
5import { LegendList } from "@legendapp/list/react-native";5import { LegendList } from "@legendapp/list/react-native";
6import {6import {
7 AuthOrchestrationOperateScope,
7 type EnvironmentId,8 type EnvironmentId,
8 type EnvironmentMachineKind,9 type EnvironmentMachineKind,
9 resolveEnvironmentMachineKind,10 resolveEnvironmentMachineKind,
...@@ -30,6 +31,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";...@@ -30,6 +31,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
30import { relativeTime } from "../../lib/time";31import { relativeTime } from "../../lib/time";
31import { useUniwindTheme } from "../../lib/useUniwindTheme";32import { useUniwindTheme } from "../../lib/useUniwindTheme";
32import { useServerConfigs } from "../../state/entities";33import { useServerConfigs } from "../../state/entities";
34import { useEnvironmentScope } from "../../state/session";
33import { ThreadSwipeable } from "../home/thread-swipe-actions";35import { ThreadSwipeable } from "../home/thread-swipe-actions";
34import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";36import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
35import { SettingsScreenContent } from "../settings/components/SettingsScreen";37import { SettingsScreenContent } from "../settings/components/SettingsScreen";
...@@ -191,24 +193,74 @@ function ArchivedThreadRow(props: {...@@ -191,24 +193,74 @@ function ArchivedThreadRow(props: {
191}) {193}) {
192 const { width: windowWidth } = useWindowDimensions();194 const { width: windowWidth } = useWindowDimensions();
193 const cardColor = useUniwindTheme()["--color-grouped-card"];195 const cardColor = useUniwindTheme()["--color-grouped-card"];
196 const canOperateThread = useEnvironmentScope(
197 props.thread.environmentId,
198 AuthOrchestrationOperateScope,
199 );
194 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);200 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
195 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>201 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
196 Boolean(part),202 Boolean(part),
197 );203 );
204 const rowContent = (
205 <View
206 className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
207 >
208 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
209 <SymbolView
210 name="archivebox.fill"
211 size={15}
212 tintColorClassName="accent-icon-subtle"
213 type="monochrome"
214 />
215 </View>
216
217 <View className="min-w-0 flex-1 gap-1">
218 <View className="flex-row items-center gap-2">
219 <Text
220 className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
221 numberOfLines={1}
222 >
223 {props.thread.title}
224 </Text>
225 <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
226 {timestamp}
227 </Text>
228 </View>
229 {subtitle.length > 0 ? (
230 <View className="flex-row items-center gap-1.5">
231 <SymbolView
232 name="arrow.triangle.branch"
233 size={10}
234 tintColorClassName="accent-icon-subtle"
235 type="monochrome"
236 />
237 <Text
238 className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"
239 numberOfLines={1}
240 >
241 {subtitle.join(" · ")}
242 </Text>
243 </View>
244 ) : null}
245 </View>
246 </View>
247 );
248 // Keep the group's rounded corners on both interactive and read-only rows.
249 const containerStyle = {
250 borderTopLeftRadius: props.isFirst ? 20 : 0,
251 borderTopRightRadius: props.isFirst ? 20 : 0,
252 borderBottomLeftRadius: props.isLast ? 20 : 0,
253 borderBottomRightRadius: props.isLast ? 20 : 0,
254 overflow: "hidden" as const,
255 };
256 if (!canOperateThread) return <View style={containerStyle}>{rowContent}</View>;
257
198 return (258 return (
199 <ThreadSwipeable259 <ThreadSwipeable
200 resetKey={`${props.thread.environmentId}:${props.thread.id}`}260 resetKey={`${props.thread.environmentId}:${props.thread.id}`}
201 threadKey={`${props.thread.environmentId}:${props.thread.id}`}261 threadKey={`${props.thread.environmentId}:${props.thread.id}`}
202 backgroundColor={cardColor}262 backgroundColor={cardColor}
203 // Round + clip the swipeable container so the group's corners stay263 containerStyle={containerStyle}
204 // rounded while rows swipe; the row itself stays square inside.
205 containerStyle={{
206 borderTopLeftRadius: props.isFirst ? 20 : 0,
207 borderTopRightRadius: props.isFirst ? 20 : 0,
208 borderBottomLeftRadius: props.isLast ? 20 : 0,
209 borderBottomRightRadius: props.isLast ? 20 : 0,
210 overflow: "hidden",
211 }}
212 fullSwipeWidth={windowWidth - 32}264 fullSwipeWidth={windowWidth - 32}
213 onDelete={props.onDelete}265 onDelete={props.onDelete}
214 onSwipeableClose={props.onSwipeableClose}266 onSwipeableClose={props.onSwipeableClose}
...@@ -222,50 +274,7 @@ function ArchivedThreadRow(props: {...@@ -222,50 +274,7 @@ function ArchivedThreadRow(props: {
222 simultaneousWith={props.simultaneousSwipeGesture}274 simultaneousWith={props.simultaneousSwipeGesture}
223 threadTitle={props.thread.title}275 threadTitle={props.thread.title}
224 >276 >
225 {() => (277 {() => rowContent}
226 <View
227 className={`flex-row items-center gap-3 bg-grouped-card px-4 py-3 ${props.isLast ? "" : "border-b border-separator"}`}
228 >
229 <View className="h-[34px] w-[34px] items-center justify-center rounded-[11px] bg-subtle">
230 <SymbolView
231 name="archivebox.fill"
232 size={15}
233 tintColorClassName="accent-icon-subtle"
234 type="monochrome"
235 />
236 </View>
237
238 <View className="min-w-0 flex-1 gap-1">
239 <View className="flex-row items-center gap-2">
240 <Text
241 className="min-w-0 flex-1 text-base font-t3-bold leading-snug text-foreground"
242 numberOfLines={1}
243 >
244 {props.thread.title}
245 </Text>
246 <Text className="min-w-[30px] text-right text-xs tabular-nums text-foreground-tertiary">
247 {timestamp}
248 </Text>
249 </View>
250 {subtitle.length > 0 ? (
251 <View className="flex-row items-center gap-1.5">
252 <SymbolView
253 name="arrow.triangle.branch"
254 size={10}
255 tintColorClassName="accent-icon-subtle"
256 type="monochrome"
257 />
258 <Text
259 className="min-w-0 flex-1 font-mono text-2xs text-foreground-tertiary"
260 numberOfLines={1}
261 >
262 {subtitle.join(" · ")}
263 </Text>
264 </View>
265 ) : null}
266 </View>
267 </View>
268 )}
269 </ThreadSwipeable>278 </ThreadSwipeable>
270 );279 );
271}280}
apps/mobile/src/features/home/useThreadListActions.test.ts created+326
...@@ -0,0 +1,326 @@
1import { makeThreadShellFixture } from "../../test-fixtures";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import {
4 AuthOrchestrationOperateScope,
5 EnvironmentId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { AsyncResult } from "effect/reactivity";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
13
14const state = vi.hoisted(() => ({
15 pendingOrder: null as object | null,
16 dropBusy: false,
17 scopes: new Map<string, Set<string>>(),
18 shells: [] as EnvironmentThreadShell[],
19 requests: [] as {
20 action: string;
21 environmentId: string;
22 input: { threadId: string; orderKey?: string };
23 }[],
24 dialogs: [] as { onConfirm: () => void }[],
25 alerts: [] as {
26 title: string;
27 buttons?: { text: string; onPress?: () => void }[];
28 }[],
29 afterRequest: undefined as (() => void) | undefined,
30}));
31
32vi.mock("react", () => ({
33 useCallback: (callback: unknown) => callback,
34 useRef: (current: unknown) => ({ current }),
35}));
36vi.mock("react-native", () => ({
37 Alert: {
38 alert: (title: string, _message: string, buttons?: { text: string; onPress?: () => void }[]) =>
39 state.alerts.push({ title, buttons }),
40 },
41}));
42vi.mock("expo-haptics", () => ({
43 impactAsync: async () => {},
44 ImpactFeedbackStyle: { Light: "light" },
45}));
46vi.mock("../../components/ConfirmDialogHost", () => ({
47 showConfirmDialog: (dialog: { onConfirm: () => void }) => state.dialogs.push(dialog),
48}));
49vi.mock("../archive/useArchivedThreadSnapshots", () => ({
50 refreshArchivedThreadsForEnvironment: () => {},
51}));
52vi.mock("../../state/session", () => ({
53 readEnvironmentScope: (environmentId: string, scope: string) =>
54 state.scopes.get(environmentId)?.has(scope) === true,
55}));
56vi.mock("../../state/server", () => ({
57 environmentServerConfigsAtom: "server-configs",
58}));
59vi.mock("../../state/atom-registry", () => ({
60 appAtomRegistry: {
61 set: (_atom: string, value: boolean) => {
62 state.dropBusy = value;
63 },
64 get: (atom: string) =>
65 atom === "thread-drop-busy"
66 ? state.dropBusy
67 : atom === "thread-shells"
68 ? state.shells
69 : atom === "queued-thread-keys"
70 ? new Set<string>()
71 : new Map(
72 [...state.scopes.keys()].map((environmentId) => [
73 environmentId,
74 {
75 environment: {
76 capabilities: {
77 threadSettlement: true,
78 threadSnooze: true,
79 threadPinning: true,
80 threadPinReorder: true,
81 threadTitleRegeneration: true,
82 },
83 },
84 },
85 ]),
86 ),
87 },
88}));
89vi.mock("../../state/use-atom-command", () => ({
90 useAtomCommand: (command: unknown) => command,
91}));
92// Stubbed at the direct dependency: the real outbox pulls the Expo file-system
93// storage into a test that only reads which threads are queued.
94vi.mock("../../state/use-thread-outbox", () => ({ queuedThreadKeysAtom: "queued-thread-keys" }));
95// The real hold lives in a module-level atom that would leak between cases.
96vi.mock("../../state/thread-order", () => ({
97 threadDropBusyAtom: "thread-drop-busy",
98 getPendingThreadOrder: () => state.pendingOrder,
99 beginPendingThreadOrder: () => {
100 state.pendingOrder = {};
101 return {
102 isPending: () => state.pendingOrder !== null,
103 complete: () => {
104 state.pendingOrder = null;
105 },
106 cancel: () => {
107 state.pendingOrder = null;
108 },
109 };
110 },
111}));
112vi.mock("../../state/threads", () => ({
113 environmentThreadShells: { threadShellsAtom: "thread-shells" },
114 threadEnvironment: Object.fromEntries(
115 [
116 "archive",
117 "unarchive",
118 "delete",
119 "settle",
120 "unsettle",
121 "snooze",
122 "unsnooze",
123 "pin",
124 "unpin",
125 "reorderPin",
126 "updateMetadata",
127 ].map((action) => [
128 action,
129 async (request: {
130 environmentId: string;
131 input: { threadId: string; orderKey?: string };
132 }) => {
133 state.requests.push({ action, ...request });
134 if (!state.scopes.get(request.environmentId)?.has(AuthOrchestrationOperateScope)) {
135 return AsyncResult.failure(Cause.fail(new Error("Thread operation denied")));
136 }
137 state.afterRequest?.();
138 return AsyncResult.success(undefined);
139 },
140 ]),
141 ),
142}));
143
144import { useArchivedThreadListActions, useThreadListActions } from "./useThreadListActions";
145
146const primaryEnvironmentId = EnvironmentId.make("primary");
147const otherEnvironmentId = EnvironmentId.make("other");
148
149function makeThread(input: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell {
150 return makeThreadShellFixture({
151 id: ThreadId.make("thread"),
152 title: "Thread",
153 environmentId: primaryEnvironmentId,
154 projectId: ProjectId.make("project"),
155 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "model" },
156 ...input,
157 });
158}
159
160const mutationCases = [
161 ["archiveThread", "archive"],
162 ["settleThread", "settle"],
163 ["unsettleThread", "unsettle"],
164 ["snoozeThread", "snooze"],
165 ["unsnoozeThread", "unsnooze"],
166 ["pinThread", "pin"],
167 ["unpinThread", "unpin"],
168 ["regenerateThreadTitle", "updateMetadata"],
169] as const;
170
171beforeEach(() => {
172 state.pendingOrder = null;
173 state.dropBusy = false;
174 state.scopes = new Map([
175 [primaryEnvironmentId, new Set([AuthOrchestrationOperateScope])],
176 [otherEnvironmentId, new Set<string>()],
177 ]);
178 state.requests = [];
179 state.shells = [];
180 state.dialogs = [];
181 state.alerts = [];
182 state.afterRequest = undefined;
183});
184
185afterEach(() => vi.unstubAllEnvs());
186
187describe("thread list operation permissions", () => {
188 it.each(mutationCases)(
189 "%s rejects a retained callback after its environment loses permission",
190 async (handler) => {
191 const actions = useThreadListActions();
192 state.scopes.get(primaryEnvironmentId)!.clear();
193
194 await actions[handler](makeThread(), "2099-01-01T00:00:00.000Z");
195
196 expect(state.requests).toEqual([]);
197 },
198 );
199
200 it.each(mutationCases)("%s requires the target environment's permission", async (handler) => {
201 await useThreadListActions()[handler](
202 makeThread({ environmentId: otherEnvironmentId }),
203 "2099-01-01T00:00:00.000Z",
204 );
205
206 expect(state.requests).toEqual([]);
207 });
208
209 it.each(mutationCases)(
210 "%s works when the target environment gains only task permission",
211 async (handler, action) => {
212 state.scopes.get(primaryEnvironmentId)!.clear();
213 const actions = useThreadListActions();
214 state.scopes.get(otherEnvironmentId)!.add(AuthOrchestrationOperateScope);
215
216 await actions[handler](
217 makeThread({ environmentId: otherEnvironmentId }),
218 "2099-01-01T00:00:00.000Z",
219 );
220
221 expect(state.requests).toEqual([
222 expect.objectContaining({ action, environmentId: otherEnvironmentId }),
223 ]);
224 },
225 );
226
227 it.each(["ios", "android"])("does not show a forbidden %s delete confirmation", (os) => {
228 vi.stubEnv("EXPO_OS", os);
229 useThreadListActions().confirmDeleteThread(makeThread({ environmentId: otherEnvironmentId }));
230
231 expect(state.dialogs).toEqual([]);
232 expect(state.alerts.some((alert) => alert.title === "Delete thread?")).toBe(false);
233 expect(state.requests).toEqual([]);
234 });
235
236 it.each(["ios", "android"])(
237 "rechecks permission after a retained %s delete confirmation",
238 async (os) => {
239 vi.stubEnv("EXPO_OS", os);
240 useThreadListActions().confirmDeleteThread(makeThread());
241 const confirm =
242 os === "ios"
243 ? state.alerts[0]?.buttons?.find((button) => button.text === "Delete")?.onPress
244 : state.dialogs[0]?.onConfirm;
245 expect(confirm).toBeTypeOf("function");
246 state.scopes.get(primaryEnvironmentId)!.clear();
247
248 await confirm!();
249
250 expect(state.requests).toEqual([]);
251 },
252 );
253
254 it("unarchives with only task permission and blocks a later revoked callback", async () => {
255 const actions = useArchivedThreadListActions(() => {});
256 const thread = makeThread({ archivedAt: "2026-09-02T00:00:00.000Z" });
257 await actions.unarchiveThread(thread);
258 expect(state.requests).toEqual([expect.objectContaining({ action: "unarchive" })]);
259 state.requests = [];
260 state.scopes.get(primaryEnvironmentId)!.clear();
261
262 await actions.unarchiveThread(thread);
263
264 expect(state.requests).toEqual([]);
265 });
266
267 it("keeps delete independent of terminal and source-control permissions", async () => {
268 vi.stubEnv("EXPO_OS", "android");
269 useArchivedThreadListActions(() => {}).confirmDeleteThread(makeThread());
270 await state.dialogs[0]!.onConfirm();
271
272 expect(state.requests).toEqual([expect.objectContaining({ action: "delete" })]);
273 });
274});
275
276describe("pinned thread operation permissions", () => {
277 it("checks every materialization target before writing any keys", async () => {
278 const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" });
279 state.shells = [
280 moved,
281 makeThread({
282 id: ThreadId.make("other-thread"),
283 environmentId: otherEnvironmentId,
284 pinnedAt: "2026-09-02T00:00:00.000Z",
285 createdAt: "2026-09-02T00:00:00.000Z",
286 }),
287 ];
288
289 expect(await useThreadListActions().moveThread(moved, "up")).toBe(false);
290 expect(state.requests).toEqual([]);
291 });
292
293 it("moves a keyed thread past a read-only neighbor without writing that neighbor", async () => {
294 const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z", pinOrderKey: "m" });
295 state.shells = [
296 moved,
297 makeThread({
298 id: ThreadId.make("other-thread"),
299 environmentId: otherEnvironmentId,
300 pinnedAt: "2026-09-02T00:00:00.000Z",
301 pinOrderKey: "g",
302 }),
303 ];
304
305 expect(await useThreadListActions().moveThread(moved, "up")).toBe(true);
306 expect(state.requests).toEqual([
307 expect.objectContaining({ action: "reorderPin", environmentId: primaryEnvironmentId }),
308 ]);
309 });
310
311 it("rechecks permission between materialization writes", async () => {
312 const moved = makeThread({ pinnedAt: "2026-09-02T00:00:00.000Z" });
313 state.shells = [
314 moved,
315 makeThread({
316 id: ThreadId.make("other-thread"),
317 pinnedAt: "2026-09-02T00:00:00.000Z",
318 createdAt: "2026-09-02T00:00:00.000Z",
319 }),
320 ];
321 state.afterRequest = () => state.scopes.get(primaryEnvironmentId)!.clear();
322
323 expect(await useThreadListActions().moveThread(moved, "up")).toBe(false);
324 expect(state.requests).toHaveLength(1);
325 });
326});
apps/mobile/src/features/home/useThreadListActions.ts+22
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import type { ThreadMoveDestination } from "../threads/threadOrder";1import type { ThreadMoveDestination } from "../threads/threadOrder";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
3import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";3import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
4import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
5import * as Haptics from "expo-haptics";6import * as Haptics from "expo-haptics";
6import { useCallback, useRef } from "react";7import { useCallback, useRef } from "react";
...@@ -13,6 +14,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre...@@ -13,6 +14,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
13import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";14import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
14import { appAtomRegistry } from "../../state/atom-registry";15import { appAtomRegistry } from "../../state/atom-registry";
15import { environmentServerConfigsAtom } from "../../state/server";16import { environmentServerConfigsAtom } from "../../state/server";
17import { readEnvironmentScope } from "../../state/session";
16import { environmentThreadShells, threadEnvironment } from "../../state/threads";18import { environmentThreadShells, threadEnvironment } from "../../state/threads";
17import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";19import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
18import { useAtomCommand } from "../../state/use-atom-command";20import { useAtomCommand } from "../../state/use-atom-command";
...@@ -108,6 +110,12 @@ function actionFailureTitle(action: ThreadListAction): string {...@@ -108,6 +110,12 @@ function actionFailureTitle(action: ThreadListAction): string {
108 return "Could not delete thread";110 return "Could not delete thread";
109}111}
110112
113function checkThreadOperationPermission(thread: EnvironmentThreadShell, title: string): boolean {
114 if (readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return true;
115 Alert.alert(title, "This connection cannot change threads.");
116 return false;
117}
118
111/** Resolves to true iff the action was dispatched and succeeded. */119/** Resolves to true iff the action was dispatched and succeeded. */
112function useThreadActionExecutor(120function useThreadActionExecutor(
113 onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,121 onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
...@@ -121,6 +129,7 @@ function useThreadActionExecutor(...@@ -121,6 +129,7 @@ function useThreadActionExecutor(
121129
122 const executeAction = useCallback(130 const executeAction = useCallback(
123 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {131 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
132 if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false;
124 const key = scopedThreadKey(thread.environmentId, thread.id);133 const key = scopedThreadKey(thread.environmentId, thread.id);
125 if (inFlightThreadKeys.current.has(key)) {134 if (inFlightThreadKeys.current.has(key)) {
126 return false;135 return false;
...@@ -205,6 +214,7 @@ function useConfirmDeleteThread(...@@ -205,6 +214,7 @@ function useConfirmDeleteThread(
205) {214) {
206 return useCallback(215 return useCallback(
207 (thread: EnvironmentThreadShell) => {216 (thread: EnvironmentThreadShell) => {
217 if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return;
208 const title = "Delete thread?";218 const title = "Delete thread?";
209 const message = `“${thread.title}” will be permanently deleted, including its terminal history.`;219 const message = `“${thread.title}” will be permanently deleted, including its terminal history.`;
210 if (process.env.EXPO_OS === "ios") {220 if (process.env.EXPO_OS === "ios") {
...@@ -281,6 +291,7 @@ export function useThreadListActions(): {...@@ -281,6 +291,7 @@ export function useThreadListActions(): {
281 );291 );
282 const snoozeThread = useCallback(292 const snoozeThread = useCallback(
283 async (thread: EnvironmentThreadShell, snoozedUntil: string) => {293 async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
294 if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;
284 const key = scopedThreadKey(thread.environmentId, thread.id);295 const key = scopedThreadKey(thread.environmentId, thread.id);
285 if (snoozeInFlightThreadKeys.current.has(key)) {296 if (snoozeInFlightThreadKeys.current.has(key)) {
286 return false;297 return false;
...@@ -336,6 +347,7 @@ export function useThreadListActions(): {...@@ -336,6 +347,7 @@ export function useThreadListActions(): {
336 );347 );
337 const unsnoozeThread = useCallback(348 const unsnoozeThread = useCallback(
338 async (thread: EnvironmentThreadShell) => {349 async (thread: EnvironmentThreadShell) => {
350 if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false;
339 const key = scopedThreadKey(thread.environmentId, thread.id);351 const key = scopedThreadKey(thread.environmentId, thread.id);
340 if (snoozeInFlightThreadKeys.current.has(key)) {352 if (snoozeInFlightThreadKeys.current.has(key)) {
341 return false;353 return false;
...@@ -383,6 +395,7 @@ export function useThreadListActions(): {...@@ -383,6 +395,7 @@ export function useThreadListActions(): {
383 );395 );
384 const pinThread = useCallback(396 const pinThread = useCallback(
385 async (thread: EnvironmentThreadShell) => {397 async (thread: EnvironmentThreadShell) => {
398 if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false;
386 if (!environmentSupportsPinning(thread.environmentId)) {399 if (!environmentSupportsPinning(thread.environmentId)) {
387 Alert.alert(400 Alert.alert(
388 "Could not pin thread",401 "Could not pin thread",
...@@ -423,6 +436,7 @@ export function useThreadListActions(): {...@@ -423,6 +436,7 @@ export function useThreadListActions(): {
423 );436 );
424 const unpinThread = useCallback(437 const unpinThread = useCallback(
425 async (thread: EnvironmentThreadShell) => {438 async (thread: EnvironmentThreadShell) => {
439 if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false;
426 if (!environmentSupportsPinning(thread.environmentId)) {440 if (!environmentSupportsPinning(thread.environmentId)) {
427 Alert.alert(441 Alert.alert(
428 "Could not unpin thread",442 "Could not unpin thread",
...@@ -479,6 +493,7 @@ export function useThreadListActions(): {...@@ -479,6 +493,7 @@ export function useThreadListActions(): {
479 );493 );
480 const regenerateThreadTitle = useCallback(494 const regenerateThreadTitle = useCallback(
481 async (thread: EnvironmentThreadShell) => {495 async (thread: EnvironmentThreadShell) => {
496 if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false;
482 const key = scopedThreadKey(thread.environmentId, thread.id);497 const key = scopedThreadKey(thread.environmentId, thread.id);
483 if (498 if (
484 thread.titleRegeneration != null ||499 thread.titleRegeneration != null ||
...@@ -572,6 +587,7 @@ export function useThreadListActions(): {...@@ -572,6 +587,7 @@ export function useThreadListActions(): {
572 });587 });
573 const moveThread = useCallback(588 const moveThread = useCallback(
574 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {589 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
590 if (!checkThreadOperationPermission(thread, "Could not move thread")) return false;
575 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;591 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
576 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);592 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
577 const current = shells.find(593 const current = shells.find(
...@@ -648,6 +664,11 @@ export function useThreadListActions(): {...@@ -648,6 +664,11 @@ export function useThreadListActions(): {
648 const shellByKey = new Map(664 const shellByKey = new Map(
649 shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),665 shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
650 );666 );
667 for (const assignment of assignments) {
668 const target = shellByKey.get(assignment.id);
669 if (target && !checkThreadOperationPermission(target, "Could not move thread"))
670 return false;
671 }
651 selectionHaptic();672 selectionHaptic();
652 appAtomRegistry.set(threadDropBusyAtom, true);673 appAtomRegistry.set(threadDropBusyAtom, true);
653 const pending = crossSection674 const pending = crossSection
...@@ -694,6 +715,7 @@ export function useThreadListActions(): {...@@ -694,6 +715,7 @@ export function useThreadListActions(): {
694 if (pending !== null && !pending.isPending()) return false;715 if (pending !== null && !pending.isPending()) return false;
695 const target = shellByKey.get(assignment.id);716 const target = shellByKey.get(assignment.id);
696 if (target === undefined) continue;717 if (target === undefined) continue;
718 if (!checkThreadOperationPermission(target, "Could not move thread")) return false;
697 const result = await reorder({719 const result = await reorder({
698 environmentId: target.environmentId,720 environmentId: target.environmentId,
699 input: { threadId: target.id, orderKey: assignment.orderKey },721 input: { threadId: target.id, orderKey: assignment.orderKey },
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx+33-7
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";3import {
4 AuthProvidersManageScope,
5 AuthOrchestrationReadScope,
6 type EnvironmentId,
7 type ServerProvider,
8} from "@t3tools/contracts";
4import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";9import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
5import { AsyncResult } from "effect/reactivity";10import { AsyncResult } from "effect/reactivity";
6import { useEffect, useRef, useState } from "react";11import { useEffect, useRef, useState } from "react";
...@@ -10,8 +15,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -10,8 +15,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
10import { AppText as Text } from "../../components/AppText";15import { AppText as Text } from "../../components/AppText";
11import { ProviderIcon } from "../../components/ProviderIcon";16import { ProviderIcon } from "../../components/ProviderIcon";
12import { ScreenScrollView } from "../../components/ScreenScrollView";17import { ScreenScrollView } from "../../components/ScreenScrollView";
18import { appAtomRegistry } from "../../state/atom-registry";
13import { serverEnvironment } from "../../state/server";19import { serverEnvironment } from "../../state/server";
14import { environmentSession } from "../../state/session";20import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
15import { useAtomCommand } from "../../state/use-atom-command";21import { useAtomCommand } from "../../state/use-atom-command";
16import { useRemoteConnections } from "../../state/use-remote-environment-registry";22import { useRemoteConnections } from "../../state/use-remote-environment-registry";
17import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";23import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
...@@ -75,7 +81,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -75,7 +81,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
75 (provider) =>81 (provider) =>
76 provider.updateState?.status === "running" || provider.updateState?.status === "queued",82 provider.updateState?.status === "running" || provider.updateState?.status === "queued",
77 ) ?? false;83 ) ?? false;
78 const disabled = !allowed || pending !== null || running || providerBusy;84 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
85 const canRead = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
86 const busy = !connected || pending !== null || running || providerBusy;
87 const disabled = !allowed || busy;
79 const version = config?.environment.serverVersion;88 const version = config?.environment.serverVersion;
80 const checkedRelease = release?.fromVersion === version ? release : null;89 const checkedRelease = release?.fromVersion === version ? release : null;
81 const capabilities = config?.environment.capabilities;90 const capabilities = config?.environment.capabilities;
...@@ -111,6 +120,16 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -111,6 +120,16 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
111 text: "Update",120 text: "Update",
112 onPress: () =>121 onPress: () =>
113 void run("server", async () => {122 void run("server", async () => {
123 if (
124 AsyncResult.isFailure(
125 appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)),
126 ) ||
127 !canMaintainEnvironment(
128 appAtomRegistry.get(environmentSession.sessionStateValueAtom(environmentId)),
129 connected,
130 )
131 )
132 return;
114 const result = await updateServer({133 const result = await updateServer({
115 environmentId,134 environmentId,
116 input: {135 input: {
...@@ -131,7 +150,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -131,7 +150,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
131 }150 }
132151
133 function requestProviderUpdate(provider: ServerProvider) {152 function requestProviderUpdate(provider: ServerProvider) {
134 if (disabled || !canUpdateEnvironmentProvider(provider)) return;153 if (
154 busy ||
155 !readEnvironmentScope(environmentId, AuthProvidersManageScope) ||
156 !canUpdateEnvironmentProvider(provider)
157 )
158 return;
135 void run(provider.instanceId, async () => {159 void run(provider.instanceId, async () => {
136 const result = await updateProvider({160 const result = await updateProvider({
137 environmentId,161 environmentId,
...@@ -274,11 +298,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -274,11 +298,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
274 <SettingsActionRow298 <SettingsActionRow
275 icon="arrow.clockwise"299 icon="arrow.clockwise"
276 label="Refresh providers"300 label="Refresh providers"
277 disabled={disabled}301 disabled={busy || !canRead}
278 loading={pending === "refresh"}302 loading={pending === "refresh"}
279 onPress={() => {303 onPress={() => {
280 if (disabled) return;304 if (busy || !canRead) return;
281 void run("refresh", async () => {305 void run("refresh", async () => {
306 if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
307 return;
282 const result = await refreshProviders({ environmentId, input: {} });308 const result = await refreshProviders({ environmentId, input: {} });
283 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);309 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
284 setNotice("Provider status refreshed.");310 setNotice("Provider status refreshed.");
...@@ -338,7 +364,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm...@@ -338,7 +364,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
338 <SettingsActionRow364 <SettingsActionRow
339 icon="arrow.up.circle"365 icon="arrow.up.circle"
340 label={`Update ${provider.displayName ?? provider.driver}`}366 label={`Update ${provider.displayName ?? provider.driver}`}
341 disabled={disabled}367 disabled={busy || !canManageProviders}
342 loading={pending === provider.instanceId}368 loading={pending === provider.instanceId}
343 onPress={() => requestProviderUpdate(provider)}369 onPress={() => requestProviderUpdate(provider)}
344 />370 />
apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx+7-1
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthRelayWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
1import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
2import { useAuth } from "@clerk/expo";4import { useAuth } from "@clerk/expo";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";5import { useAtomSet, useAtomValue } from "@effect/atom-react";
...@@ -271,6 +273,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {...@@ -271,6 +273,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {
271 const updateResult = await settleAsyncResult(() =>273 const updateResult = await settleAsyncResult(() =>
272 runtime.runPromiseExit(274 runtime.runPromiseExit(
273 setLiveActivityUpdatesEnabled({275 setLiveActivityUpdatesEnabled({
276 canConfigureEnvironment: (environmentId) =>
277 readEnvironmentScope(environmentId, AuthRelayWriteScope),
274 enabled: true,278 enabled: true,
275 previousEnabled: liveActivitiesPreferenceEnabled,279 previousEnabled: liveActivitiesPreferenceEnabled,
276 clerkToken: tokenResult.value,280 clerkToken: tokenResult.value,
...@@ -302,7 +306,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {...@@ -302,7 +306,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {
302 Alert.alert(306 Alert.alert(
303 Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled",307 Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled",
304 environmentCount > 0308 environmentCount > 0
305 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for agent activity updates.`309 ? "Agent activity updates are enabled for environments this connection can configure."
306 : "Agent activity updates are enabled. Add an environment to start receiving updates.",310 : "Agent activity updates are enabled. Add an environment to start receiving updates.",
307 );311 );
308 } else {312 } else {
...@@ -370,6 +374,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {...@@ -370,6 +374,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {
370 const updateResult = await settleAsyncResult(() =>374 const updateResult = await settleAsyncResult(() =>
371 runtime.runPromiseExit(375 runtime.runPromiseExit(
372 setLiveActivityUpdatesEnabled({376 setLiveActivityUpdatesEnabled({
377 canConfigureEnvironment: (environmentId) =>
378 readEnvironmentScope(environmentId, AuthRelayWriteScope),
373 enabled: false,379 enabled: false,
374 previousEnabled: liveActivitiesPreferenceEnabled,380 previousEnabled: liveActivitiesPreferenceEnabled,
375 clerkToken: token,381 clerkToken: token,
apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts+60-1
...@@ -1,6 +1,65 @@...@@ -1,6 +1,65 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";4import {
5 resolveAgentAwarenessPlatformPresentation,
6 resolveAutoSettleReferenceEnvironmentId,
7} from "./SettingsRouteScreen.logic";
8
9describe("resolveAutoSettleReferenceEnvironmentId", () => {
10 const firstId = EnvironmentId.make("first");
11 const secondId = EnvironmentId.make("second");
12
13 it("waits for an earlier grant before exposing a later writable reference", () => {
14 const second = { environmentId: secondId, canWriteSettings: true };
15 expect(
16 resolveAutoSettleReferenceEnvironmentId([
17 { environmentId: firstId, canWriteSettings: null },
18 second,
19 ]),
20 ).toBeNull();
21 expect(
22 resolveAutoSettleReferenceEnvironmentId([
23 { environmentId: firstId, canWriteSettings: false },
24 second,
25 ]),
26 ).toBe(secondId);
27 expect(
28 resolveAutoSettleReferenceEnvironmentId([
29 { environmentId: firstId, canWriteSettings: true },
30 second,
31 ]),
32 ).toBe(firstId);
33 });
34
35 it("does not wait for later grants after finding the first writable reference", () => {
36 expect(
37 resolveAutoSettleReferenceEnvironmentId([
38 { environmentId: firstId, canWriteSettings: true },
39 { environmentId: secondId, canWriteSettings: null },
40 ]),
41 ).toBe(firstId);
42 });
43
44 it("waits before showing a read-only fallback until all grants are resolved", () => {
45 expect(
46 resolveAutoSettleReferenceEnvironmentId([
47 { environmentId: firstId, canWriteSettings: false },
48 { environmentId: secondId, canWriteSettings: null },
49 ]),
50 ).toBeNull();
51 expect(
52 resolveAutoSettleReferenceEnvironmentId([
53 { environmentId: firstId, canWriteSettings: false },
54 { environmentId: secondId, canWriteSettings: false },
55 ]),
56 ).toBe(firstId);
57 });
58
59 it("has no reference when no environment supports synchronization", () => {
60 expect(resolveAutoSettleReferenceEnvironmentId([])).toBeNull();
61 });
62});
463
5describe("resolveAgentAwarenessPlatformPresentation", () => {64describe("resolveAgentAwarenessPlatformPresentation", () => {
6 it("supports agent awareness settings on Android", () => {65 it("supports agent awareness settings on Android", () => {
apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts+16
...@@ -1,3 +1,19 @@...@@ -1,3 +1,19 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2
3/** Wait for earlier grants before choosing the settings that edits and synchronization use. */
4export function resolveAutoSettleReferenceEnvironmentId(
5 environments: ReadonlyArray<{
6 readonly environmentId: EnvironmentId;
7 readonly canWriteSettings: boolean | null;
8 }>,
9): EnvironmentId | null {
10 for (const environment of environments) {
11 if (environment.canWriteSettings === null) return null;
12 if (environment.canWriteSettings) return environment.environmentId;
13 }
14 return environments[0]?.environmentId ?? null;
15}
16
1export function resolveAgentAwarenessPlatformPresentation(platform: string): {17export function resolveAgentAwarenessPlatformPresentation(platform: string): {
2 readonly supported: boolean;18 readonly supported: boolean;
3 readonly subtitle: string | undefined;19 readonly subtitle: string | undefined;
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+28-6
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { readEnvironmentScope } from "../../state/session";
1import type {4import type {
2 EnvironmentId,5 EnvironmentId,
3 ProjectId,6 ProjectId,
...@@ -580,6 +583,9 @@ function TaskForm({...@@ -580,6 +583,9 @@ function TaskForm({
580 const projects = useProjects().filter((project) => project.environmentId === environmentId);583 const projects = useProjects().filter((project) => project.environmentId === environmentId);
581 const config = useEnvironmentServerConfig(environmentId);584 const config = useEnvironmentServerConfig(environmentId);
582 const modelOptions = useMemo(() => buildModelOptions(config, null), [config]);585 const modelOptions = useMemo(() => buildModelOptions(config, null), [config]);
586 const canOperate = useAtomValue(
587 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
588 );
583 const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, {589 const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, {
584 label: "scheduled task upsert",590 label: "scheduled task upsert",
585 reportFailure: false,591 reportFailure: false,
...@@ -602,6 +608,7 @@ function TaskForm({...@@ -602,6 +608,7 @@ function TaskForm({
602608
603 const save = async () => {609 const save = async () => {
604 if (610 if (
611 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
605 submissionPending.current ||612 submissionPending.current ||
606 saving ||613 saving ||
607 dictationPending ||614 dictationPending ||
...@@ -937,7 +944,7 @@ function TaskForm({...@@ -937,7 +944,7 @@ function TaskForm({
937 : draft.task944 : draft.task
938 }945 }
939 signatureConfigured={draft.schedule.signature !== null}946 signatureConfigured={draft.schedule.signature !== null}
940 disabled={saving || environmentUnavailable}947 disabled={!canOperate || saving || environmentUnavailable}
941 />948 />
942 <FormField949 <FormField
943 label="Skip requests older than (minutes)"950 label="Skip requests older than (minutes)"
...@@ -994,7 +1001,9 @@ function TaskForm({...@@ -994,7 +1001,9 @@ function TaskForm({
994 accessibilityState={{1001 accessibilityState={{
995 disabled: saving || dictationPending || taskMissing || environmentUnavailable,1002 disabled: saving || dictationPending || taskMissing || environmentUnavailable,
996 }}1003 }}
997 disabled={saving || dictationPending || taskMissing || environmentUnavailable}1004 disabled={
1005 !canOperate || saving || dictationPending || taskMissing || environmentUnavailable
1006 }
998 onPress={() => void save()}1007 onPress={() => void save()}
999 className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50"1008 className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50"
1000 >1009 >
...@@ -1118,6 +1127,9 @@ function EnvironmentTasks({...@@ -1118,6 +1127,9 @@ function EnvironmentTasks({
1118 const visibleTasks = tasks.data?.tasks.filter(1127 const visibleTasks = tasks.data?.tasks.filter(
1119 (task) => projectIds === null || projectIds.includes(task.projectId),1128 (task) => projectIds === null || projectIds.includes(task.projectId),
1120 );1129 );
1130 const canOperate = useAtomValue(
1131 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
1132 );
1121 const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {1133 const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {
1122 label: "scheduled task enabled",1134 label: "scheduled task enabled",
1123 reportFailure: false,1135 reportFailure: false,
...@@ -1137,6 +1149,7 @@ function EnvironmentTasks({...@@ -1137,6 +1149,7 @@ function EnvironmentTasks({
1137 };1149 };
11381150
1139 const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => {1151 const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => {
1152 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
1140 const result =1153 const result =
1141 action === "run"1154 action === "run"
1142 ? await runNow({ environmentId, input: { id: task.id } })1155 ? await runNow({ environmentId, input: { id: task.id } })
...@@ -1205,10 +1218,19 @@ function EnvironmentTasks({...@@ -1205,10 +1218,19 @@ function EnvironmentTasks({
1205 <ControlPillMenu1218 <ControlPillMenu
1206 actions={[1219 actions={[
1207 { id: "edit", title: "Edit" },1220 { id: "edit", title: "Edit" },
1208 { id: "toggle", title: task.enabled ? "Pause" : "Resume" },1221 {
1209 // A webhook task has no request to run without.1222 id: "toggle",
1210 ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]),1223 title: task.enabled ? "Pause" : "Resume",
1211 { id: "delete", title: "Delete", attributes: { destructive: true } },1224 attributes: { disabled: !canOperate },
1225 },
1226 ...(task.schedule.type === "webhook"
1227 ? []
1228 : [{ id: "run", title: "Run now", attributes: { disabled: !canOperate } }]),
1229 {
1230 id: "delete",
1231 title: "Delete",
1232 attributes: { destructive: true, disabled: !canOperate },
1233 },
1212 ]}1234 ]}
1213 onPressAction={({ nativeEvent }) => {1235 onPressAction={({ nativeEvent }) => {
1214 const action = nativeEvent.event;1236 const action = nativeEvent.event;
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+26-3
...@@ -1,5 +1,7 @@...@@ -1,5 +1,7 @@
1import { useNavigation } from "@react-navigation/native";1import { useNavigation } from "@react-navigation/native";
2import { SettingsRow } from "./components/SettingsRow";2import { SettingsRow } from "./components/SettingsRow";
3import { AuthSettingsWriteScope } from "@t3tools/contracts";
4import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
3import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";5import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
4import { AppText as Text } from "../../components/AppText";6import { AppText as Text } from "../../components/AppText";
5import {7import {
...@@ -141,6 +143,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -141,6 +143,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
141 const navigation = useNavigation();143 const navigation = useNavigation();
142 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();144 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
143 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);145 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
146 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
144 const projectSelected = selectedProjectKey !== null;147 const projectSelected = selectedProjectKey !== null;
145 const targets = resolveMobileSettingsTargets(148 const targets = resolveMobileSettingsTargets(
146 selectedTargets,149 selectedTargets,
...@@ -151,6 +154,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -151,6 +154,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
151 const [pendingTargets, setPendingTargets] = useState<154 const [pendingTargets, setPendingTargets] = useState<
152 readonly ScopedMobileSettingsTarget[] | null155 readonly ScopedMobileSettingsTarget[] | null
153 >(null);156 >(null);
157 const canWriteSettings =
158 targets.length > 0 &&
159 targets.every((target) => writableEnvironments.has(target.environment.environmentId));
154 const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets;160 const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets;
155 const hasConnectedSelection = targets.length > 0;161 const hasConnectedSelection = targets.length > 0;
156 const reference = displayTargets[0] ?? null;162 const reference = displayTargets[0] ?? null;
...@@ -165,7 +171,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -165,7 +171,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
165 reportFailure: true,171 reportFailure: true,
166 });172 });
167 const write = (patch: ServerSettingsPatch) => {173 const write = (patch: ServerSettingsPatch) => {
168 if (writeInFlight.current || !hasConnectedSelection) return;174 if (
175 writeInFlight.current ||
176 !hasConnectedSelection ||
177 !targets.every((target) =>
178 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
179 )
180 )
181 return;
169 const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch);182 const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch);
170 if (writes.length === 0) return;183 if (writes.length === 0) return;
171 writeInFlight.current = true;184 writeInFlight.current = true;
...@@ -182,7 +195,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -182,7 +195,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
182 });195 });
183 };196 };
184 const clearProjectOverrides = () => {197 const clearProjectOverrides = () => {
185 if (writeInFlight.current) return;198 if (
199 writeInFlight.current ||
200 !targets.every((target) =>
201 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
202 )
203 )
204 return;
186 const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]);205 const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]);
187 if (writes.length === 0) return;206 if (writes.length === 0) return;
188 writeInFlight.current = true;207 writeInFlight.current = true;
...@@ -203,7 +222,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -203,7 +222,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
203 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,222 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
204 );223 );
205 const disabled =224 const disabled =
206 pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides);225 !canWriteSettings ||
226 pendingWrites > 0 ||
227 !hasConnectedSelection ||
228 (projectSelected && !supportsProjectOverrides);
207 const supportsContinuation = targets.every(229 const supportsContinuation = targets.every(
208 (target) =>230 (target) =>
209 target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,231 target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,
...@@ -245,6 +267,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {...@@ -245,6 +267,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
245 )}267 )}
246 supportsOverrides={supportsProjectOverrides}268 supportsOverrides={supportsProjectOverrides}
247 pending={pendingWrites > 0}269 pending={pendingWrites > 0}
270 disabled={!canWriteSettings}
248 onClear={clearProjectOverrides}271 onClear={clearProjectOverrides}
249 />272 />
250 ) : null}273 ) : null}
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+23-3
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
1import { AutoSettleDaysField } from "./components/AutoSettleDaysField";3import { AutoSettleDaysField } from "./components/AutoSettleDaysField";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";4import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import { useAtomSet, useAtomValue } from "@effect/atom-react";5import { useAtomSet, useAtomValue } from "@effect/atom-react";
...@@ -61,6 +63,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD...@@ -61,6 +63,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD
61function AutoSettleSettingsRows() {63function AutoSettleSettingsRows() {
62 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();64 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
63 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);65 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
66 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
64 const projectSelected = selectedProjectKey !== null;67 const projectSelected = selectedProjectKey !== null;
65 const [pendingWrites, setPendingWrites] = useState(0);68 const [pendingWrites, setPendingWrites] = useState(0);
66 const writeInFlight = useRef(false);69 const writeInFlight = useRef(false);
...@@ -77,6 +80,9 @@ function AutoSettleSettingsRows() {...@@ -77,6 +80,9 @@ function AutoSettleSettingsRows() {
77 syncEnvironments,80 syncEnvironments,
78 projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null,81 projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null,
79 );82 );
83 const canWriteSettings =
84 syncTargets.length > 0 &&
85 syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId));
80 const displayTargets =86 const displayTargets =
81 pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets;87 pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets;
82 const reference = displayTargets[0] ?? null;88 const reference = displayTargets[0] ?? null;
...@@ -92,7 +98,13 @@ function AutoSettleSettingsRows() {...@@ -92,7 +98,13 @@ function AutoSettleSettingsRows() {
92 snoozeLimitedThreads?: boolean;98 snoozeLimitedThreads?: boolean;
93 },99 },
94 ) => {100 ) => {
95 if (writeInFlight.current) return;101 if (
102 writeInFlight.current ||
103 !syncTargets.every((target) =>
104 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
105 )
106 )
107 return;
96 const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch);108 const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch);
97 if (writes.length === 0) return;109 if (writes.length === 0) return;
98 writeInFlight.current = true;110 writeInFlight.current = true;
...@@ -127,7 +139,8 @@ function AutoSettleSettingsRows() {...@@ -127,7 +139,8 @@ function AutoSettleSettingsRows() {
127 (target) =>139 (target) =>
128 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,140 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
129 );141 );
130 const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);142 const disabled =
143 !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
131 const hasProjectOverrides =144 const hasProjectOverrides =
132 projectSelected &&145 projectSelected &&
133 syncTargets.some(146 syncTargets.some(
...@@ -136,7 +149,13 @@ function AutoSettleSettingsRows() {...@@ -136,7 +149,13 @@ function AutoSettleSettingsRows() {
136 target.sources.sidebarAutoSettleAfterDays === "project",149 target.sources.sidebarAutoSettleAfterDays === "project",
137 );150 );
138 const clearProjectOverrides = () => {151 const clearProjectOverrides = () => {
139 if (writeInFlight.current) return;152 if (
153 writeInFlight.current ||
154 !syncTargets.every((target) =>
155 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
156 )
157 )
158 return;
140 const writes = planMobileScopedSettingsClear(syncTargets, [159 const writes = planMobileScopedSettingsClear(syncTargets, [
141 "sidebarAutoSettleOnMerge",160 "sidebarAutoSettleOnMerge",
142 "sidebarAutoSettleAfterDays",161 "sidebarAutoSettleAfterDays",
...@@ -166,6 +185,7 @@ function AutoSettleSettingsRows() {...@@ -166,6 +185,7 @@ function AutoSettleSettingsRows() {
166 hasOverrides={hasProjectOverrides}185 hasOverrides={hasProjectOverrides}
167 supportsOverrides={supportsProjectOverrides}186 supportsOverrides={supportsProjectOverrides}
168 pending={pendingWrites > 0}187 pending={pendingWrites > 0}
188 disabled={!canWriteSettings}
169 onClear={clearProjectOverrides}189 onClear={clearProjectOverrides}
170 />190 />
171 ) : null}191 ) : null}
apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx+2
...@@ -8,6 +8,7 @@ export function SettingsProjectOverridesSection(props: {...@@ -8,6 +8,7 @@ export function SettingsProjectOverridesSection(props: {
8 readonly hasOverrides: boolean;8 readonly hasOverrides: boolean;
9 readonly supportsOverrides: boolean;9 readonly supportsOverrides: boolean;
10 readonly pending: boolean;10 readonly pending: boolean;
11 readonly disabled?: boolean;
11 readonly onClear: () => void;12 readonly onClear: () => void;
12}) {13}) {
13 return (14 return (
...@@ -20,6 +21,7 @@ export function SettingsProjectOverridesSection(props: {...@@ -20,6 +21,7 @@ export function SettingsProjectOverridesSection(props: {
20 <Pressable21 <Pressable
21 accessibilityRole="button"22 accessibilityRole="button"
22 accessibilityLabel="Use environment defaults"23 accessibilityLabel="Use environment defaults"
24 disabled={props.disabled}
23 onPress={props.onClear}25 onPress={props.onClear}
24 className="px-2 py-2 active:opacity-70"26 className="px-2 py-2 active:opacity-70"
25 >27 >
apps/mobile/src/features/settings/environment-maintenance.test.ts+23
...@@ -53,6 +53,29 @@ describe("environment maintenance access", () => {...@@ -53,6 +53,29 @@ describe("environment maintenance access", () => {
53 expect(canMaintainEnvironment(null, true)).toBe(false);53 expect(canMaintainEnvironment(null, true)).toBe(false);
54 });54 });
5555
56 it.each([
57 { scopes: ["environment:maintain"], expected: true },
58 { scopes: ["orchestration:operate"], expected: false },
59 { scopes: ["providers:manage"], expected: false },
60 ] as const)("uses the advertised update permission for $scopes", ({ scopes, expected }) => {
61 expect(
62 canMaintainEnvironment(
63 {
64 authenticated: true,
65 auth: {
66 policy: "remote-reachable",
67 bootstrapMethods: [],
68 sessionMethods: [],
69 sessionCookieName: "session",
70 serverUpdateScope: "environment:maintain",
71 },
72 scopes,
73 },
74 true,
75 ),
76 ).toBe(expected);
77 });
78
56 it("requires remote desktop update support for desktop hosts", () => {79 it("requires remote desktop update support for desktop hosts", () => {
57 expect(supportsEnvironmentUpdate({})).toBe(false);80 expect(supportsEnvironmentUpdate({})).toBe(false);
58 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);81 expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true);
apps/mobile/src/features/settings/environment-maintenance.ts+2-1
...@@ -16,7 +16,8 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect...@@ -16,7 +16,8 @@ export function canMaintainEnvironment(session: AuthSessionState | null, connect
16 return (16 return (
17 connected &&17 connected &&
18 session?.authenticated === true &&18 session?.authenticated === true &&
19 session.scopes?.includes(AuthOrchestrationOperateScope) === true19 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
20 true
20 );21 );
21}22}
2223
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+20
...@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
28import { useUniwindTheme } from "../../lib/useUniwindTheme";28import { useUniwindTheme } from "../../lib/useUniwindTheme";
29import { useFontFamily } from "../../lib/useFontFamily";29import { useFontFamily } from "../../lib/useFontFamily";
30import {30import {
31 AuthOrchestrationOperateScope,
31 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,32 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
32 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,33 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
33 resolveEnvironmentMachineKind,34 resolveEnvironmentMachineKind,
...@@ -133,6 +134,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha...@@ -133,6 +134,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha
133import { appAtomRegistry } from "../../state/atom-registry";134import { appAtomRegistry } from "../../state/atom-registry";
134import { serverEnvironment } from "../../state/server";135import { serverEnvironment } from "../../state/server";
135import { fileRoutePathSegments } from "../files/filePath";136import { fileRoutePathSegments } from "../files/filePath";
137import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
136138
137function NewTaskWorkspaceIcon(props: {139function NewTaskWorkspaceIcon(props: {
138 readonly workspaceMode: "local" | "worktree";140 readonly workspaceMode: "local" | "worktree";
...@@ -219,6 +221,12 @@ export function NewTaskDraftScreen(props: {...@@ -219,6 +221,12 @@ export function NewTaskDraftScreen(props: {
219 connectedEnvironments.find(221 connectedEnvironments.find(
220 (environment) => environment.environmentId === selectedProject.environmentId,222 (environment) => environment.environmentId === selectedProject.environmentId,
221 )?.connectionState === "connected";223 )?.connectionState === "connected";
224 const canOperate = useEnvironmentScope(
225 selectedProject?.environmentId ?? null,
226 AuthOrchestrationOperateScope,
227 );
228 const taskPermissionReason =
229 environmentConnected && !canOperate ? "This connection cannot start tasks." : null;
222 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;230 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
223 // A project added by cloning exists before its files do: the prompt can be231 // A project added by cloning exists before its files do: the prompt can be
224 // written meanwhile, but Start waits for the clone.232 // written meanwhile, but Start waits for the clone.
...@@ -1214,6 +1222,12 @@ export function NewTaskDraftScreen(props: {...@@ -1214,6 +1222,12 @@ export function NewTaskDraftScreen(props: {
1214 if (!selectedProject || !draftKey) {1222 if (!selectedProject || !draftKey) {
1215 return;1223 return;
1216 }1224 }
1225 if (
1226 environmentConnected &&
1227 !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope)
1228 ) {
1229 return;
1230 }
1217 const draft = getComposerDraftSnapshot(draftKey);1231 const draft = getComposerDraftSnapshot(draftKey);
1218 if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return;1232 if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return;
1219 // Read the latest explicit pick. Antigravity selections stay unchanged1233 // Read the latest explicit pick. Antigravity selections stay unchanged
...@@ -1378,6 +1392,7 @@ export function NewTaskDraftScreen(props: {...@@ -1378,6 +1392,7 @@ export function NewTaskDraftScreen(props: {
1378 const canStart =1392 const canStart =
1379 !isImportingContext &&1393 !isImportingContext &&
1380 !cloneBlocksStart &&1394 !cloneBlocksStart &&
1395 taskPermissionReason === null &&
1381 attachmentBlockReason === null &&1396 attachmentBlockReason === null &&
1382 !modelUnavailable &&1397 !modelUnavailable &&
1383 Boolean(flow.selectedProject) &&1398 Boolean(flow.selectedProject) &&
...@@ -1655,6 +1670,10 @@ export function NewTaskDraftScreen(props: {...@@ -1655,6 +1670,10 @@ export function NewTaskDraftScreen(props: {
1655 ) : null}1670 ) : null}
1656 {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null}1671 {flow.canChooseWorkspace ? <View className="pb-1">{workspaceControls}</View> : null}
16571672
1673 {taskPermissionReason ? (
1674 <Text className="px-3 py-2 text-xs text-muted-foreground">{taskPermissionReason}</Text>
1675 ) : null}
1676
1658 {modelUnavailable ? (1677 {modelUnavailable ? (
1659 <Pressable1678 <Pressable
1660 accessibilityRole="button"1679 accessibilityRole="button"
...@@ -1794,6 +1813,7 @@ export function NewTaskDraftScreen(props: {...@@ -1794,6 +1813,7 @@ export function NewTaskDraftScreen(props: {
1794 {voicePresentation.showsSend ? (1813 {voicePresentation.showsSend ? (
1795 <ComposerActionButton1814 <ComposerActionButton
1796 accessibilityLabel={1815 accessibilityLabel={
1816 taskPermissionReason ??
1797 attachmentBlockReason ??1817 attachmentBlockReason ??
1798 (cloneBlocksStart1818 (cloneBlocksStart
1799 ? projectClone === null || projectClone.phase === "running"1819 ? projectClone === null || projectClone.phase === "running"
apps/mobile/src/features/threads/PendingApprovalCard.tsx+7-1
...@@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText";
10import type { PendingApproval } from "../../lib/threadActivity";10import type { PendingApproval } from "../../lib/threadActivity";
1111
12export interface PendingApprovalCardProps {12export interface PendingApprovalCardProps {
13 readonly canOperateThread: boolean;
13 readonly approval: PendingApproval;14 readonly approval: PendingApproval;
14 readonly respondingApprovalId: RuntimeRequestId | null;15 readonly respondingApprovalId: RuntimeRequestId | null;
15 readonly onRespond: (16 readonly onRespond: (
...@@ -66,11 +67,16 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {...@@ -66,11 +67,16 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
66 ? "danger"67 ? "danger"
67 : "secondary"68 : "secondary"
68 }69 }
69 disabled={disabled}70 disabled={disabled || !props.canOperateThread}
70 onPress={() => void props.onRespond(props.approval.requestId, option.decision)}71 onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
71 />72 />
72 ))}73 ))}
73 </View>74 </View>
75 {!props.canOperateThread ? (
76 <Text className="font-sans text-xs text-adaptive-neutral-500-400">
77 This connection cannot respond to approvals.
78 </Text>
79 ) : null}
74 </View>80 </View>
75 );81 );
76}82}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+8-1
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28} from "../../lib/threadActivity";28} from "../../lib/threadActivity";
2929
30export interface PendingUserInputCardProps {30export interface PendingUserInputCardProps {
31 readonly canOperateThread: boolean;
31 readonly pendingUserInput: PendingUserInput;32 readonly pendingUserInput: PendingUserInput;
32 /**33 /**
33 * Constant while a request is pending (it reserves keyboard space), so the34 * Constant while a request is pending (it reserves keyboard space), so the
...@@ -200,6 +201,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -200,6 +201,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
200 icon="stop.fill"201 icon="stop.fill"
201 variant="danger"202 variant="danger"
202 className="h-9 w-9"203 className="h-9 w-9"
204 disabled={!props.canOperateThread}
203 onPress={props.onStopThread}205 onPress={props.onStopThread}
204 />206 />
205 ) : null}207 ) : null}
...@@ -342,7 +344,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -342,7 +344,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
342 label="Submit answers"344 label="Submit answers"
343 size="large"345 size="large"
344 tone={props.answers ? "primary" : "secondary"}346 tone={props.answers ? "primary" : "secondary"}
345 disabled={responseDisabled || props.answers === null}347 disabled={!props.canOperateThread || responseDisabled || props.answers === null}
346 onPress={() => void props.onSubmit()}348 onPress={() => void props.onSubmit()}
347 />349 />
348 {props.pendingUserInput.dismissible ? (350 {props.pendingUserInput.dismissible ? (
...@@ -357,6 +359,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -357,6 +359,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
357 </Text>359 </Text>
358 </Pressable>360 </Pressable>
359 ) : null}361 ) : null}
362 {!props.canOperateThread ? (
363 <Text className="font-sans text-xs text-foreground-tertiary">
364 This connection cannot submit answers.
365 </Text>
366 ) : null}
360 </Animated.View>367 </Animated.View>
361 ) : null;368 ) : null;
362 return (369 return (
apps/mobile/src/features/threads/ThreadComposer.tsx+10
...@@ -140,6 +140,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;...@@ -140,6 +140,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
140export const COMPOSER_EXPANDED_CHROME = 156;140export const COMPOSER_EXPANDED_CHROME = 156;
141141
142export interface ThreadComposerProps {142export interface ThreadComposerProps {
143 readonly canOperateThread: boolean;
143 readonly draftMessage: string;144 readonly draftMessage: string;
144 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;145 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
145 readonly placeholder: string;146 readonly placeholder: string;
...@@ -539,6 +540,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -539,6 +540,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
539 (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ??540 (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ??
540 attachmentBlockReason;541 attachmentBlockReason;
541 const canSend =542 const canSend =
543 (props.canOperateThread || props.connectionState !== "connected") &&
542 hasContent &&544 hasContent &&
543 !contextImports[composerDraftKey] &&545 !contextImports[composerDraftKey] &&
544 !voiceInput.blocksSubmission &&546 !voiceInput.blocksSubmission &&
...@@ -1056,6 +1058,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -1056,6 +1058,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
1056 accessibilityLabel="Stop agent"1058 accessibilityLabel="Stop agent"
1057 icon="stop.fill"1059 icon="stop.fill"
1058 variant="danger"1060 variant="danger"
1061 disabled={!props.canOperateThread}
1059 onPress={props.onStopThread}1062 onPress={props.onStopThread}
1060 />1063 />
1061 ) : (1064 ) : (
...@@ -1150,6 +1153,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -1150,6 +1153,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
1150 accessibilityLabel="Stop agent"1153 accessibilityLabel="Stop agent"
1151 icon="stop.fill"1154 icon="stop.fill"
1152 variant="danger"1155 variant="danger"
1156 disabled={!props.canOperateThread}
1153 onPress={props.onStopThread}1157 onPress={props.onStopThread}
1154 />1158 />
1155 ) : voicePresentation.showsSend ? (1159 ) : voicePresentation.showsSend ? (
...@@ -1165,6 +1169,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -1165,6 +1169,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
1165 </ComposerDictationToolbar>1169 </ComposerDictationToolbar>
1166 </Animated.View>1170 </Animated.View>
1167 </ComposerSurface>1171 </ComposerSurface>
1172
1173 {props.connectionState === "connected" && !props.canOperateThread ? (
1174 <Text className="pt-2 text-xs text-foreground-muted">
1175 This connection cannot control this task. You can still edit your draft.
1176 </Text>
1177 ) : null}
1168 </Animated.View>1178 </Animated.View>
11691179
1170 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />1180 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4
...@@ -148,6 +148,7 @@ import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";...@@ -148,6 +148,7 @@ import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";
148export interface ThreadDetailScreenProps {148export interface ThreadDetailScreenProps {
149 readonly worktreeSetup?: WorktreeSetupCardProps | null;149 readonly worktreeSetup?: WorktreeSetupCardProps | null;
150 readonly setupWorkingStartedAt?: string | null;150 readonly setupWorkingStartedAt?: string | null;
151 readonly canOperateThread: boolean;
151 readonly selectedThread: EnvironmentThreadShell;152 readonly selectedThread: EnvironmentThreadShell;
152 readonly contentPresentation: ThreadContentPresentation;153 readonly contentPresentation: ThreadContentPresentation;
153 readonly screenTone: StatusTone;154 readonly screenTone: StatusTone;
...@@ -1297,6 +1298,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1297,6 +1298,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1297 >1298 >
1298 {props.activePendingApproval ? (1299 {props.activePendingApproval ? (
1299 <PendingApprovalCard1300 <PendingApprovalCard
1301 canOperateThread={props.canOperateThread}
1300 approval={props.activePendingApproval}1302 approval={props.activePendingApproval}
1301 respondingApprovalId={props.respondingApprovalId}1303 respondingApprovalId={props.respondingApprovalId}
1302 onRespond={props.onRespondToApproval}1304 onRespond={props.onRespondToApproval}
...@@ -1304,6 +1306,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1304,6 +1306,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1304 ) : null}1306 ) : null}
1305 {props.activePendingUserInput ? (1307 {props.activePendingUserInput ? (
1306 <PendingUserInputCard1308 <PendingUserInputCard
1309 canOperateThread={props.canOperateThread}
1307 pendingUserInput={props.activePendingUserInput}1310 pendingUserInput={props.activePendingUserInput}
1308 maxHeight={pendingUserInputMaxHeight}1311 maxHeight={pendingUserInputMaxHeight}
1309 collapsed={userInputCollapsed}1312 collapsed={userInputCollapsed}
...@@ -1371,6 +1374,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1371,6 +1374,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1371 ) : (1374 ) : (
1372 <>1375 <>
1373 <ThreadComposer1376 <ThreadComposer
1377 canOperateThread={props.canOperateThread}
1374 reportedModelSelection={reportedModelSelection}1378 reportedModelSelection={reportedModelSelection}
1375 editorRef={composerEditorRef}1379 editorRef={composerEditorRef}
1376 draftMessage={props.draftMessage}1380 draftMessage={props.draftMessage}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+12-1
...@@ -15,11 +15,13 @@ import {...@@ -15,11 +15,13 @@ import {
15import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";15import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
16import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import {17import {
18 AuthOrchestrationOperateScope,
18 DEFAULT_SERVER_SETTINGS,19 DEFAULT_SERVER_SETTINGS,
19 EnvironmentId,20 EnvironmentId,
20 ThreadId,21 ThreadId,
21 type ProjectScript,22 type ProjectScript,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
23import {25import {
24 projectScriptCwd,26 projectScriptCwd,
25 projectScriptRuntimeEnv,27 projectScriptRuntimeEnv,
...@@ -333,6 +335,10 @@ function ThreadRouteContent(...@@ -333,6 +335,10 @@ function ThreadRouteContent(
333 selectedThreadProject,335 selectedThreadProject,
334 selectedEnvironmentConnection,336 selectedEnvironmentConnection,
335 } = useThreadSelection();337 } = useThreadSelection();
338 const canOperateThread = useEnvironmentScope(
339 selectedThread?.environmentId ?? null,
340 AuthOrchestrationOperateScope,
341 );
336 const selectedThreadDetailState = props.selectedThreadDetailState;342 const selectedThreadDetailState = props.selectedThreadDetailState;
337 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);343 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
338 const { selectedThreadCwd } = useSelectedThreadWorktree();344 const { selectedThreadCwd } = useSelectedThreadWorktree();
...@@ -673,7 +679,11 @@ function ThreadRouteContent(...@@ -673,7 +679,11 @@ function ThreadRouteContent(
673 void navigation.navigate("Connections");679 void navigation.navigate("Connections");
674 }, [navigation]);680 }, [navigation]);
675 const handleStopThread = useCallback(() => {681 const handleStopThread = useCallback(() => {
676 if (!selectedThread || composer.interruptibleRunId === null) {682 if (
683 !selectedThread ||
684 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) ||
685 composer.interruptibleRunId === null
686 ) {
677 return;687 return;
678 }688 }
679 return interruptThreadTurn({689 return interruptThreadTurn({
...@@ -988,6 +998,7 @@ function ThreadRouteContent(...@@ -988,6 +998,7 @@ function ThreadRouteContent(
988998
989 <View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas">999 <View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas">
990 <ThreadDetailScreen1000 <ThreadDetailScreen
1001 canOperateThread={canOperateThread}
991 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}1002 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
992 contentPresentation={contentPresentation}1003 contentPresentation={contentPresentation}
993 screenTone={connectionTone(routeConnectionState)}1004 screenTone={connectionTone(routeConnectionState)}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+8-3
...@@ -17,7 +17,7 @@ import type {...@@ -17,7 +17,7 @@ import type {
17 EnvironmentThreadShell,17 EnvironmentThreadShell,
18} from "@t3tools/client-runtime/state/shell";18} from "@t3tools/client-runtime/state/shell";
19import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";19import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
20import type { EnvironmentMachineKind } from "@t3tools/contracts";20import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
21import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";21import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
22import type { MenuAction } from "@react-native-menu/menu";22import type { MenuAction } from "@react-native-menu/menu";
23import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";23import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
...@@ -34,6 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco...@@ -34,6 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco
34import { cn } from "../../lib/cn";34import { cn } from "../../lib/cn";
35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
36import { useUniwindTheme } from "../../lib/useUniwindTheme";36import { useUniwindTheme } from "../../lib/useUniwindTheme";
37import { useEnvironmentScope } from "../../state/session";
37import type { PendingNewTask } from "../../state/use-pending-new-tasks";38import type { PendingNewTask } from "../../state/use-pending-new-tasks";
38import { useThreadPr } from "../../state/use-thread-pr";39import { useThreadPr } from "../../state/use-thread-pr";
39import { useSwipeRowDormant } from "../home/swipe-row-activation";40import { useSwipeRowDormant } from "../home/swipe-row-activation";
...@@ -600,6 +601,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -600,6 +601,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
600601
601 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);602 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
602 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);603 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
604 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
603 const handleRegenerateTitle = useCallback(605 const handleRegenerateTitle = useCallback(
604 () => onRegenerateThreadTitle(thread),606 () => onRegenerateThreadTitle(thread),
605 [onRegenerateThreadTitle, thread],607 [onRegenerateThreadTitle, thread],
...@@ -917,8 +919,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -917,8 +919,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
917 : null,919 : null,
918 [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title],920 [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title],
919 );921 );
920 const swipeAccessibilityHint =922 const swipeAccessibilityHint = !canOperateThread
921 secondaryAction === null923 ? "Opens the thread"
924 : secondaryAction === null
922 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`925 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
923 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;926 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;
924927
...@@ -1224,6 +1227,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1224,6 +1227,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1224 </RowPressable>1227 </RowPressable>
1225 );1228 );
12261229
1230 if (!canOperateThread) return rowContent(() => {});
1231
1227 return (1232 return (
1228 <View collapsable={false}>1233 <View collapsable={false}>
1229 {customSnoozeOpen && (1234 {customSnoozeOpen && (
apps/mobile/src/features/usage/UsageLimitsSection.test.ts+4
...@@ -9,6 +9,10 @@ const state = vi.hoisted(() => ({...@@ -9,6 +9,10 @@ const state = vi.hoisted(() => ({
9 autoRefresh: async () => {},9 autoRefresh: async () => {},
10 refreshingRef: { current: false },10 refreshingRef: { current: false },
11}));11}));
12vi.mock("../../state/session", () => ({
13 useEnvironmentScope: () => true,
14 readEnvironmentScope: () => true,
15}));
12vi.mock("react", () => ({16vi.mock("react", () => ({
13 useState: (initial: unknown) => {17 useState: (initial: unknown) => {
14 const index = state.cursor++;18 const index = state.cursor++;
apps/mobile/src/features/usage/UsageLimitsSection.tsx+22-12
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type {2import {
3 EnvironmentId,3 AuthProvidersManageScope,
4 ProviderConsumeResetCreditOutcome,4 type EnvironmentId,
5 ProviderConsumeResetCreditInput,5 type ProviderConsumeResetCreditOutcome,
6 ServerProvider,6 type ProviderConsumeResetCreditInput,
7 ServerProviderResetCredits,7 type ServerProvider,
8 ServerProviderUsageWindow,8 type ServerProviderResetCredits,
9 UsageProviderKind,9 type ServerProviderUsageWindow,
10 type UsageProviderKind,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import {12import {
12 elapsedShare,13 elapsedShare,
...@@ -24,6 +25,7 @@ import { AppText as Text } from "../../components/AppText";...@@ -24,6 +25,7 @@ import { AppText as Text } from "../../components/AppText";
24import { ProviderIcon } from "../../components/ProviderIcon";25import { ProviderIcon } from "../../components/ProviderIcon";
25import { environmentPresentations } from "../../state/presentation";26import { environmentPresentations } from "../../state/presentation";
26import { serverEnvironment } from "../../state/server";27import { serverEnvironment } from "../../state/server";
28import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
27import { useAtomCommand } from "../../state/use-atom-command";29import { useAtomCommand } from "../../state/use-atom-command";
28import { useProviderColors } from "./usageProviders";30import { useProviderColors } from "./usageProviders";
2931
...@@ -207,6 +209,7 @@ export function ResetCredits(props: {...@@ -207,6 +209,7 @@ export function ResetCredits(props: {
207 readonly dense?: boolean;209 readonly dense?: boolean;
208}) {210}) {
209 const { environmentId, input, credits, now, dense = false } = props;211 const { environmentId, input, credits, now, dense = false } = props;
212 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
210 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {213 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
211 reportFailure: false,214 reportFailure: false,
212 });215 });
...@@ -225,6 +228,7 @@ export function ResetCredits(props: {...@@ -225,6 +228,7 @@ export function ResetCredits(props: {
225 }`;228 }`;
226229
227 const redeem = async () => {230 const redeem = async () => {
231 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
228 setBusy(true);232 setBusy(true);
229 setStatus(null);233 setStatus(null);
230 const result = await consume({ environmentId, input });234 const result = await consume({ environmentId, input });
...@@ -241,6 +245,7 @@ export function ResetCredits(props: {...@@ -241,6 +245,7 @@ export function ResetCredits(props: {
241 };245 };
242246
243 const confirm = () => {247 const confirm = () => {
248 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
244 Alert.alert(249 Alert.alert(
245 "Use a reset credit?",250 "Use a reset credit?",
246 "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",251 "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
...@@ -257,13 +262,13 @@ export function ResetCredits(props: {...@@ -257,13 +262,13 @@ export function ResetCredits(props: {
257 {credits.availableCount > 0 ? (262 {credits.availableCount > 0 ? (
258 <Pressable263 <Pressable
259 accessibilityRole="button"264 accessibilityRole="button"
260 accessibilityState={{ disabled: busy }}265 accessibilityState={{ disabled: busy || !canManageProviders }}
261 disabled={busy}266 disabled={busy || !canManageProviders}
262 onPress={confirm}267 onPress={confirm}
263 className={268 className={
264 dense269 dense
265 ? "rounded-full bg-subtle-strong px-2.5 py-1"270 ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]"
266 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5"271 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
267 }272 }
268 >273 >
269 <Text274 <Text
...@@ -277,6 +282,11 @@ export function ResetCredits(props: {...@@ -277,6 +282,11 @@ export function ResetCredits(props: {
277 </Text>282 </Text>
278 </Pressable>283 </Pressable>
279 ) : null}284 ) : null}
285 {!canManageProviders ? (
286 <Text className="text-xs text-foreground-tertiary">
287 This connection cannot manage provider accounts.
288 </Text>
289 ) : null}
280 {status ? <Text className="text-sm text-foreground">{status}</Text> : null}290 {status ? <Text className="text-sm text-foreground">{status}</Text> : null}
281 </View>291 </View>
282 );292 );
apps/mobile/src/lib/attachmentUpload.test.ts+77
...@@ -3,6 +3,7 @@ import * as Option from "effect/Option";...@@ -3,6 +3,7 @@ import * as Option from "effect/Option";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
5const mocks = vi.hoisted(() => ({5const mocks = vi.hoisted(() => ({
6 canOperate: true,
6 documentUri: "file:///documents",7 documentUri: "file:///documents",
7 createAssetUrl: vi.fn(),8 createAssetUrl: vi.fn(),
8 createUploadUrl: Symbol("create-upload-url"),9 createUploadUrl: Symbol("create-upload-url"),
...@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({...@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({
48}));49}));
4950
50vi.mock("../state/session", () => ({51vi.mock("../state/session", () => ({
52 readEnvironmentScope: () => mocks.canOperate,
51 environmentSession: {53 environmentSession: {
52 preparedConnectionValueAtom: () => mocks.preparedConnection,54 preparedConnectionValueAtom: () => mocks.preparedConnection,
53 },55 },
...@@ -194,6 +196,7 @@ function removeCallsFor(attachmentId: string): number {...@@ -194,6 +196,7 @@ function removeCallsFor(attachmentId: string): number {
194196
195describe("prepareTurnAttachments", () => {197describe("prepareTurnAttachments", () => {
196 beforeEach(() => {198 beforeEach(() => {
199 mocks.canOperate = true;
197 mocks.documentUri = "file:///documents";200 mocks.documentUri = "file:///documents";
198 mocks.createAssetUrl.mockReset();201 mocks.createAssetUrl.mockReset();
199 mocks.createAssetUrl.mockImplementation((target: unknown) => target);202 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
...@@ -222,6 +225,80 @@ describe("prepareTurnAttachments", () => {...@@ -222,6 +225,80 @@ describe("prepareTurnAttachments", () => {
222 mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} });225 mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} });
223 });226 });
224227
228 it("does not mint or transfer attachments without task operation access", async () => {
229 mocks.canOperate = false;
230 await expect(
231 prepareTurnAttachments({
232 environmentId,
233 attachments: [fileBackedImage],
234 supportsImageUploads: true,
235 }),
236 ).rejects.toThrow("cannot upload attachments");
237 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
238 expect(mocks.upload).not.toHaveBeenCalled();
239 });
240
241 it("rechecks access after a signed URL is minted before sending any bytes", async () => {
242 mocks.runAtomCommand.mockImplementationOnce(async () => {
243 mocks.canOperate = false;
244 return {
245 _tag: "Success",
246 value: {
247 attachmentId: MINTED_ID,
248 relativeUrl: "/api/attachments/upload/signed",
249 expiresAt: 1,
250 },
251 };
252 });
253 const warning = vi.spyOn(console, "warn").mockImplementation(() => {});
254 try {
255 await expect(
256 prepareTurnAttachments({
257 environmentId,
258 attachments: [fileBackedImage],
259 supportsImageUploads: true,
260 }),
261 ).rejects.toThrow("cannot upload attachments");
262 expect(mocks.upload).not.toHaveBeenCalled();
263 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
264 } finally {
265 warning.mockRestore();
266 }
267 });
268
269 it("does not replace an expired upload when access was revoked during verification", async () => {
270 mocks.executeAtomQuery.mockImplementationOnce(async () => {
271 mocks.canOperate = false;
272 return { _tag: "Failure", error: { _tag: "AssetAttachmentNotFoundError" } };
273 });
274 await expect(
275 prepareTurnAttachments({
276 environmentId,
277 attachments: [
278 {
279 ...fileBackedImage,
280 uploadedAttachmentId: MINTED_ID,
281 uploadEnvironmentId: environmentId,
282 },
283 ],
284 supportsImageUploads: true,
285 }),
286 ).rejects.toThrow("cannot upload attachments");
287 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
288 expect(mocks.upload).not.toHaveBeenCalled();
289 });
290
291 it("rechecks deletion permission before retrying a failed cleanup", async () => {
292 mocks.runAtomCommand.mockImplementationOnce(async () => {
293 mocks.canOperate = false;
294 return { _tag: "Failure", error: new Error("Retry cleanup") };
295 });
296 await expect(releasePendingAttachmentUploads(environmentId, [MINTED_ID])).rejects.toThrow(
297 "cannot delete pending attachments",
298 );
299 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
300 });
301
225 it("keeps existing image attachments on the legacy wire path", async () => {302 it("keeps existing image attachments on the legacy wire path", async () => {
226 const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] });303 const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] });
227304
apps/mobile/src/lib/attachmentUpload.ts+23-2
...@@ -13,13 +13,16 @@ import type {...@@ -13,13 +13,16 @@ import type {
13 EnvironmentId,13 EnvironmentId,
14 UploadChatImageAttachment,14 UploadChatImageAttachment,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES } from "@t3tools/contracts";16import {
17 AuthOrchestrationOperateScope,
18 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
19} from "@t3tools/contracts";
17import * as Option from "effect/Option";20import * as Option from "effect/Option";
1821
19import { appAtomRegistry } from "../state/atom-registry";22import { appAtomRegistry } from "../state/atom-registry";
20import { assetEnvironment } from "../state/assets";23import { assetEnvironment } from "../state/assets";
21import { attachmentEnvironment } from "../state/attachments";24import { attachmentEnvironment } from "../state/attachments";
22import { environmentSession } from "../state/session";25import { environmentSession, readEnvironmentScope } from "../state/session";
23import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";26import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
24import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";27import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
25import {28import {
...@@ -109,6 +112,9 @@ export async function releasePendingAttachmentUploads(...@@ -109,6 +112,9 @@ export async function releasePendingAttachmentUploads(
109 attachmentIds: ReadonlyArray<string>,112 attachmentIds: ReadonlyArray<string>,
110): Promise<void> {113): Promise<void> {
111 const deleteOnce = async (attachmentId: string): Promise<boolean> => {114 const deleteOnce = async (attachmentId: string): Promise<boolean> => {
115 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
116 throw new Error("This connection cannot delete pending attachments.");
117 }
112 const result = await runAtomCommand(118 const result = await runAtomCommand(
113 appAtomRegistry,119 appAtomRegistry,
114 attachmentEnvironment.remove,120 attachmentEnvironment.remove,
...@@ -255,6 +261,7 @@ async function composerImageAttachmentDataUrl(...@@ -255,6 +261,7 @@ async function composerImageAttachmentDataUrl(
255}261}
256262
257async function uploadFileBytes(263async function uploadFileBytes(
264 environmentId: EnvironmentId,
258 attachment: DraftComposerAttachment,265 attachment: DraftComposerAttachment,
259 url: string,266 url: string,
260 signal: AbortSignal,267 signal: AbortSignal,
...@@ -262,6 +269,9 @@ async function uploadFileBytes(...@@ -262,6 +269,9 @@ async function uploadFileBytes(
262): Promise<void> {269): Promise<void> {
263 const { File, Paths, UploadType } = await import("expo-file-system");270 const { File, Paths, UploadType } = await import("expo-file-system");
264 if (signal.aborted) throw new Error("Upload cancelled.");271 if (signal.aborted) throw new Error("Upload cancelled.");
272 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
273 throw new Error("This connection cannot upload attachments.");
274 }
265 // Legacy image drafts persisted inline bytes and stage them in a temp cache275 // Legacy image drafts persisted inline bytes and stage them in a temp cache
266 // file for the native uploader. Everything else uploads its owned copy.276 // file for the native uploader. Everything else uploads its owned copy.
267 const fileUri = attachment.fileUri;277 const fileUri = attachment.fileUri;
...@@ -349,6 +359,13 @@ export async function prepareTurnAttachments(input: {...@@ -349,6 +359,13 @@ export async function prepareTurnAttachments(input: {
349 }359 }
350 }360 }
351361
362 const requireUploadAccess = () => {
363 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
364 throw new Error("This connection cannot upload attachments.");
365 }
366 };
367 requireUploadAccess();
368
352 const connection = appAtomRegistry.get(369 const connection = appAtomRegistry.get(
353 environmentSession.preparedConnectionValueAtom(environmentId),370 environmentSession.preparedConnectionValueAtom(environmentId),
354 );371 );
...@@ -365,6 +382,7 @@ export async function prepareTurnAttachments(input: {...@@ -365,6 +382,7 @@ export async function prepareTurnAttachments(input: {
365 try {382 try {
366 for (const attachment of input.attachments) {383 for (const attachment of input.attachments) {
367 if (controller.signal.aborted) throw new Error("Upload cancelled.");384 if (controller.signal.aborted) throw new Error("Upload cancelled.");
385 requireUploadAccess();
368 if (attachment.type === "image" && !input.supportsImageUploads) {386 if (attachment.type === "image" && !input.supportsImageUploads) {
369 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));387 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
370 continue;388 continue;
...@@ -393,6 +411,7 @@ export async function prepareTurnAttachments(input: {...@@ -393,6 +411,7 @@ export async function prepareTurnAttachments(input: {
393 // "missing": the pending upload expired, upload the bytes again.411 // "missing": the pending upload expired, upload the bytes again.
394 }412 }
395413
414 requireUploadAccess();
396 const result = await runAttachmentUploadCycle({415 const result = await runAttachmentUploadCycle({
397 registry: appAtomRegistry,416 registry: appAtomRegistry,
398 createUploadUrl: attachmentEnvironment.createUploadUrl,417 createUploadUrl: attachmentEnvironment.createUploadUrl,
...@@ -402,6 +421,7 @@ export async function prepareTurnAttachments(input: {...@@ -402,6 +421,7 @@ export async function prepareTurnAttachments(input: {
402 // Read the connection at transfer time: the environment may have421 // Read the connection at transfer time: the environment may have
403 // reconnected on a new base URL since this cycle started.422 // reconnected on a new base URL since this cycle started.
404 resolveUploadUrl: (relativeUrl) => {423 resolveUploadUrl: (relativeUrl) => {
424 requireUploadAccess();
405 const currentConnection = appAtomRegistry.get(425 const currentConnection = appAtomRegistry.get(
406 environmentSession.preparedConnectionValueAtom(environmentId),426 environmentSession.preparedConnectionValueAtom(environmentId),
407 );427 );
...@@ -411,6 +431,7 @@ export async function prepareTurnAttachments(input: {...@@ -411,6 +431,7 @@ export async function prepareTurnAttachments(input: {
411 },431 },
412 transport: (url) => ({432 transport: (url) => ({
413 done: uploadFileBytes(433 done: uploadFileBytes(
434 environmentId,
414 attachment,435 attachment,
415 url,436 url,
416 controller.signal,437 controller.signal,
apps/mobile/src/state/attachments.test.ts created+99
...@@ -0,0 +1,99 @@
1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5} from "@t3tools/contracts";
6import { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments";
7import * as Cause from "effect/Cause";
8import { AsyncResult, AtomRegistry } from "effect/reactivity";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 grantedEnvironments: new Set<string>(),
13 create: vi.fn(),
14 remove: vi.fn(),
15}));
16vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
17vi.mock("./session", () => ({
18 readEnvironmentScope: (environmentId: string, scope: string) =>
19 scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId),
20}));
21vi.mock("@t3tools/client-runtime/state/attachments", async (importOriginal) => ({
22 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/attachments")>()),
23 createAttachmentEnvironmentAtoms: () => ({
24 createUploadUrl: { label: "create", run: state.create },
25 remove: { label: "remove", run: state.remove },
26 }),
27}));
28
29import { attachmentEnvironment } from "./attachments";
30
31const environmentId = EnvironmentId.make("secondary");
32const registry = AtomRegistry.make();
33beforeEach(() => {
34 state.grantedEnvironments = new Set(["primary"]);
35 state.create.mockReset().mockResolvedValue(
36 AsyncResult.success({
37 attachmentId: "pending",
38 relativeUrl: "/upload",
39 expiresAt: 1,
40 }),
41 );
42 state.remove.mockReset().mockResolvedValue(AsyncResult.success(undefined));
43});
44afterEach(() => {
45 vi.restoreAllMocks();
46});
47
48describe("attachment mutation grants", () => {
49 it("uses the attachment environment for both mint and deletion", async () => {
50 const mintResult = await attachmentEnvironment.createUploadUrl.run(registry, {
51 environmentId,
52 input: { name: "image.png", mimeType: "image/png", sizeBytes: 1 },
53 });
54 const removeResult = await attachmentEnvironment.remove.run(registry, {
55 environmentId,
56 input: { attachmentId: "pending" },
57 });
58 for (const result of [mintResult, removeResult]) {
59 expect(result._tag).toBe("Failure");
60 if (result._tag !== "Failure") throw new Error("Expected permission denial");
61 const error = Cause.squash<unknown>(result.cause);
62 expect(error).toBeInstanceOf(EnvironmentAuthorizationError);
63 expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
64 }
65 expect(state.create).not.toHaveBeenCalled();
66 expect(state.remove).not.toHaveBeenCalled();
67 state.grantedEnvironments.add("secondary");
68 expect(
69 (
70 await attachmentEnvironment.remove.run(registry, {
71 environmentId,
72 input: { attachmentId: "pending" },
73 })
74 )._tag,
75 ).toBe("Success");
76 expect(state.remove).toHaveBeenCalledOnce();
77 });
78
79 it("blocks the upload cycle's asynchronous cleanup after grant revocation", async () => {
80 state.grantedEnvironments.add("secondary");
81 const transport = vi.fn();
82 const result = await runAttachmentUploadCycle({
83 registry,
84 ...attachmentEnvironment,
85 environmentId,
86 upload: { name: "image.png", mimeType: "image/png", sizeBytes: 1 },
87 resolveUploadUrl: () => "https://example.test/upload",
88 transport,
89 onMinted: () => {
90 state.grantedEnvironments.delete("secondary");
91 return "cancel";
92 },
93 });
94 expect(result.status).toBe("cancelled");
95 expect(state.create).toHaveBeenCalledOnce();
96 expect(state.remove).not.toHaveBeenCalled();
97 expect(transport).not.toHaveBeenCalled();
98 });
99});
apps/mobile/src/state/attachments.ts+35-1
...@@ -1,5 +1,39 @@...@@ -1,5 +1,39 @@
1import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";1import { createAttachmentEnvironmentAtoms } from "@t3tools/client-runtime/state/attachments";
2import type { AtomCommand } from "@t3tools/client-runtime/state/runtime";
3import {
4 AuthOrchestrationOperateScope,
5 EnvironmentAuthorizationError,
6 type EnvironmentId,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
210
3import { connectionAtomRuntime } from "../connection/runtime";11import { connectionAtomRuntime } from "../connection/runtime";
12import { readEnvironmentScope } from "./session";
413
5export const attachmentEnvironment = createAttachmentEnvironmentAtoms(connectionAtomRuntime);14function requireAttachmentWriteAccess<W extends { readonly environmentId: EnvironmentId }, A, E>(
15 command: AtomCommand<W, A, E>,
16): AtomCommand<W, A, E | EnvironmentAuthorizationError> {
17 return {
18 ...command,
19 run: async (registry, input) => {
20 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) {
21 return AsyncResult.failure(
22 Cause.fail(
23 new EnvironmentAuthorizationError({
24 message: "This connection cannot change attachments.",
25 requiredScope: AuthOrchestrationOperateScope,
26 }),
27 ),
28 );
29 }
30 return command.run(registry, input);
31 },
32 };
33}
34
35const commands = createAttachmentEnvironmentAtoms(connectionAtomRuntime);
36export const attachmentEnvironment = {
37 createUploadUrl: requireAttachmentWriteAccess(commands.createUploadUrl),
38 remove: requireAttachmentWriteAccess(commands.remove),
39};
apps/mobile/src/state/composer-attachment-uploads.ts+14-3
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";3import { Atom } from "effect/reactivity";
4import { useEffect, useRef } from "react";4import { useEffect, useRef } from "react";
55
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24 setComposerDraftAttachmentUpload,24 setComposerDraftAttachmentUpload,
25} from "./use-composer-drafts";25} from "./use-composer-drafts";
26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";26import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
27import { readEnvironmentScope, useEnvironmentsWithScope } from "./session";
2728
28export {29export {
29 composerAttachmentUploadBlockReason,30 composerAttachmentUploadBlockReason,
...@@ -48,6 +49,7 @@ export function useComposerAttachmentUploadState(...@@ -48,6 +49,7 @@ export function useComposerAttachmentUploadState(
48}49}
4950
50export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) {51export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) {
52 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
51 uploadQueue?.retry(environmentId, attachmentId);53 uploadQueue?.retry(environmentId, attachmentId);
52}54}
5355
...@@ -57,6 +59,10 @@ export function useComposerAttachmentUploadWorker() {...@@ -57,6 +59,10 @@ export function useComposerAttachmentUploadWorker() {
57 const queuedMessages = useThreadOutboxMessages();59 const queuedMessages = useThreadOutboxMessages();
58 const serverConfigs = useServerConfigs();60 const serverConfigs = useServerConfigs();
59 const { connectedEnvironments } = useRemoteConnectionStatus();61 const { connectedEnvironments } = useRemoteConnectionStatus();
62 const operableEnvironments = useEnvironmentsWithScope(
63 connectedEnvironments,
64 AuthOrchestrationOperateScope,
65 );
60 const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null);66 const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null);
6167
62 useEffect(() => {68 useEffect(() => {
...@@ -117,7 +123,12 @@ export function useComposerAttachmentUploadWorker() {...@@ -117,7 +123,12 @@ export function useComposerAttachmentUploadWorker() {
117 );123 );
118 const requests = Object.entries(drafts).flatMap(([key, draft]) => {124 const requests = Object.entries(drafts).flatMap(([key, draft]) => {
119 const environmentId = composerDraftEnvironmentId(key, queued, draft);125 const environmentId = composerDraftEnvironmentId(key, queued, draft);
120 if (environmentId === null || !connected.has(environmentId)) return [];126 if (
127 environmentId === null ||
128 !connected.has(environmentId) ||
129 !operableEnvironments.has(environmentId)
130 )
131 return [];
121 return draft.attachments132 return draft.attachments
122 .filter((attachment) =>133 .filter((attachment) =>
123 canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)),134 canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)),
...@@ -125,5 +136,5 @@ export function useComposerAttachmentUploadWorker() {...@@ -125,5 +136,5 @@ export function useComposerAttachmentUploadWorker() {
125 .map((attachment) => ({ environmentId, attachment }));136 .map((attachment) => ({ environmentId, attachment }));
126 });137 });
127 queueRef.current?.sync(requests);138 queueRef.current?.sync(requests);
128 }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]);139 }, [connectedEnvironments, drafts, operableEnvironments, queuedMessages, serverConfigs]);
129}140}
apps/mobile/src/state/session.test.ts created+100
...@@ -0,0 +1,100 @@
1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 type AuthSessionState,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult, Atom } from "effect/reactivity";
8import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
9
10const harness = vi.hoisted(() => ({ lastAtom: null as Atom.Atom<unknown> | null }));
11vi.mock("react", () => ({ useMemo: <A>(factory: () => A) => factory() }));
12vi.mock("@effect/atom-react", async () => {
13 const { appAtomRegistry } = await import("./atom-registry");
14 return {
15 useAtomValue: <A>(atom: Atom.Atom<A>) => {
16 harness.lastAtom = atom;
17 return appAtomRegistry.get(atom);
18 },
19 };
20});
21vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} }));
22vi.mock("@t3tools/client-runtime/state/session", () => ({
23 createEnvironmentSessionAtoms: () => ({
24 sessionStateAtom: Atom.family((_id: EnvironmentId) =>
25 Atom.make(AsyncResult.initial<AuthSessionState, Error>()).pipe(Atom.keepAlive),
26 ),
27 }),
28}));
29
30import { appAtomRegistry } from "./atom-registry";
31import {
32 environmentSession,
33 readEnvironmentScope,
34 useEnvironmentScope,
35 useEnvironmentsWithScope,
36} from "./session";
37
38const primary = EnvironmentId.make("primary");
39const secondary = EnvironmentId.make("secondary");
40const session = (canOperate: boolean): AuthSessionState => ({
41 authenticated: true,
42 scopes: canOperate ? [AuthOrchestrationOperateScope] : [],
43 auth: {
44 policy: "remote-reachable",
45 bootstrapMethods: [],
46 sessionMethods: [],
47 sessionCookieName: "session",
48 },
49});
50// The production atom is read-only; the fake session source is writable.
51const source = (id: EnvironmentId) =>
52 environmentSession.sessionStateAtom(id) as unknown as Atom.Writable<
53 AsyncResult.AsyncResult<AuthSessionState, Error>
54 >;
55const releases: Array<() => void> = [];
56beforeEach(() => {
57 harness.lastAtom = null;
58 appAtomRegistry.set(source(primary), AsyncResult.success(session(true)));
59 appAtomRegistry.set(source(secondary), AsyncResult.initial());
60});
61afterEach(() => {
62 releases.splice(0).forEach((release) => release());
63});
64
65it("keeps pending and denied environments out of worker grants while retaining valid cached access", () => {
66 expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false);
67 expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false);
68 const environments = [{ environmentId: primary }, { environmentId: secondary }];
69 expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual(
70 new Set([primary]),
71 );
72 appAtomRegistry.set(source(secondary), AsyncResult.waiting(AsyncResult.success(session(true))));
73 expect(readEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(true);
74 expect(useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope)).toEqual(
75 new Set([primary, secondary]),
76 );
77 appAtomRegistry.set(source(secondary), AsyncResult.success(session(false)));
78 expect(useEnvironmentScope(secondary, AuthOrchestrationOperateScope)).toBe(false);
79});
80
81it("reacts to grant revocation, failure, and regrant without a connection list change", () => {
82 const environments = [{ environmentId: primary }, { environmentId: secondary }];
83 useEnvironmentsWithScope(environments, AuthOrchestrationOperateScope);
84 const observed = harness.lastAtom;
85 if (!observed) throw new Error("Missing worker grant atom");
86 const changes: unknown[] = [];
87 releases.push(appAtomRegistry.subscribe(observed, (value) => changes.push(value)));
88 appAtomRegistry.set(source(secondary), AsyncResult.success(session(true)));
89 expect(appAtomRegistry.get(observed)).toEqual(new Set([primary, secondary]));
90 appAtomRegistry.set(source(secondary), AsyncResult.success(session(false)));
91 expect(appAtomRegistry.get(observed)).toEqual(new Set([primary]));
92 appAtomRegistry.set(
93 source(primary),
94 AsyncResult.failure(Cause.fail(new Error("Session lookup failed"))),
95 );
96 expect(appAtomRegistry.get(observed)).toEqual(new Set());
97 appAtomRegistry.set(source(secondary), AsyncResult.success(session(true)));
98 expect(appAtomRegistry.get(observed)).toEqual(new Set([secondary]));
99 expect(changes).not.toHaveLength(0);
100});
apps/mobile/src/state/session.ts+60-2
...@@ -1,13 +1,71 @@...@@ -1,13 +1,71 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { Atom } from "effect/reactivity";5import { AsyncResult, Atom } from "effect/reactivity";
6import { useMemo } from "react";
67
7import { connectionAtomRuntime } from "../connection/runtime";8import { connectionAtomRuntime } from "../connection/runtime";
9import { appAtomRegistry } from "./atom-registry";
810
9export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);11export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
1012
13const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());
14
15function sessionHasScope(
16 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
17 scope: AuthEnvironmentScope,
18): boolean {
19 const session = Option.getOrNull(AsyncResult.value(result));
20 return (
21 result._tag !== "Failure" &&
22 session?.authenticated === true &&
23 session.scopes?.includes(scope) === true
24 );
25}
26
27/** Uses the selected environment's grant, including cached scopes during a refresh. */
28export function useEnvironmentScope(
29 environmentId: EnvironmentId | null,
30 scope: AuthEnvironmentScope,
31): boolean {
32 const result = useAtomValue(
33 environmentId === null
34 ? EMPTY_SESSION_STATE_ATOM
35 : environmentSession.sessionStateAtom(environmentId),
36 );
37 return sessionHasScope(result, scope);
38}
39
40/** Keeps background workers subscribed to each target's grant. */
41export function useEnvironmentsWithScope(
42 environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>,
43 scope: AuthEnvironmentScope,
44): ReadonlySet<EnvironmentId> {
45 const permittedEnvironments = useMemo(
46 () =>
47 Atom.make((get) => {
48 const permitted = new Set<EnvironmentId>();
49 for (const { environmentId } of environments) {
50 if (sessionHasScope(get(environmentSession.sessionStateAtom(environmentId)), scope)) {
51 permitted.add(environmentId);
52 }
53 }
54 return permitted;
55 }),
56 [environments, scope],
57 );
58 return useAtomValue(permittedEnvironments);
59}
60
61export function readEnvironmentScope(
62 environmentId: EnvironmentId,
63 scope: AuthEnvironmentScope,
64): boolean {
65 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
66 return sessionHasScope(result, scope);
67}
68
11const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(69const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
12 Atom.withLabel("mobile-prepared-connection:empty"),70 Atom.withLabel("mobile-prepared-connection:empty"),
13);71);
apps/mobile/src/state/thread-task-permissions.test.ts created+260
...@@ -0,0 +1,260 @@
1import { AuthOrchestrationOperateScope, RuntimeRequestId } from "@t3tools/contracts";
2import type { DraftComposerImageAttachment } from "../lib/composerImages";
3import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
4import { AsyncResult, type Atom } from "effect/reactivity";
5import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 grantedEnvironments: new Set<string>(),
9 connectionState: "connected",
10 draft: { text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[] },
11 pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests,
12 enqueue: vi.fn(async () => undefined),
13 clearDraft: vi.fn(),
14 approve: vi.fn(),
15 answer: vi.fn(),
16 feedback: vi.fn(),
17 updateSettings: vi.fn(),
18 question: {
19 id: "language",
20 header: "Language",
21 question: "Which language?",
22 options: [{ label: "TypeScript", description: "Use the existing code" }],
23 multiSelect: false,
24 },
25 thread: {
26 id: "thread",
27 environmentId: "secondary",
28 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
29 runtimeMode: "full-access",
30 interactionMode: "default",
31 runtime: null,
32 latestRun: null,
33 },
34}));
35
36vi.mock("react", () => ({
37 useCallback: <A>(callback: A) => callback,
38 useEffect: () => {},
39 useRef: <A>(current: A) => ({ current }),
40 useMemo: <A>(factory: () => A) => factory(),
41 useState: <A>(initial: A | (() => A)) => [
42 typeof initial === "function" ? (initial as () => A)() : initial,
43 vi.fn(),
44 ],
45}));
46vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" }));
47vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() }));
48vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" }));
49vi.mock("./queued-run-edit", () => ({
50 useQueuedRunEdit: () => null,
51 getQueuedRunEdit: () => null,
52}));
53vi.mock("./preferences", async () => {
54 const { Atom, AsyncResult } = await import("effect/reactivity");
55 return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) };
56});
57vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
58vi.mock("@effect/atom-react", async () => {
59 const { appAtomRegistry } = await import("./atom-registry");
60 return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) };
61});
62vi.mock("./entities", () => ({ useServerConfigs: () => new Map() }));
63vi.mock("./session", () => ({
64 readEnvironmentScope: (environmentId: string, scope: string) =>
65 scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId),
66}));
67vi.mock("./use-thread-selection", () => ({
68 useThreadSelection: () => ({
69 selectedThread: state.thread,
70 selectedThreadCreation: null,
71 selectedEnvironmentRuntime: {
72 connectionState: state.connectionState,
73 serverConfig: {
74 providers: [{ instanceId: "codex", driver: "codex" }],
75 environment: { capabilities: {} },
76 },
77 },
78 }),
79}));
80vi.mock("./use-thread-detail", () => ({
81 useSelectedThreadProjection: () => null,
82 useSelectedThreadVisibleTurnItems: () => [],
83 useSelectedThreadPendingRequests: () => state.pendingRequests,
84}));
85vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command }));
86vi.mock("./threads", async () => {
87 const { Atom } = await import("effect/reactivity");
88 return {
89 environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) },
90 threadEnvironment: {
91 respondToApproval: state.approve,
92 respondToUserInput: state.answer,
93 uploadFeedback: state.feedback,
94 },
95 };
96});
97vi.mock("./use-thread-outbox", async () => {
98 const { Atom } = await import("effect/reactivity");
99 return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) };
100});
101vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue }));
102vi.mock("./composer-attachment-uploads", async () => {
103 const { Atom } = await import("effect/reactivity");
104 return {
105 composerAttachmentUploadsAtom: Atom.make({}),
106 composerAttachmentUploadBlockReason: () => null,
107 composerAttachmentsStillUploading: () => false,
108 };
109});
110vi.mock("./use-composer-drafts", async () => {
111 const { Atom } = await import("effect/reactivity");
112 return {
113 composerDraftsAtom: Atom.make({}),
114 composerContextImportsAtom: Atom.make({}),
115 getComposerDraftSnapshot: () => state.draft,
116 clearComposerDraftContent: state.clearDraft,
117 clearComposerDraft: state.clearDraft,
118 setComposerDraftText: (_key: string, text: string) => {
119 state.draft.text = text;
120 },
121 updateComposerDraftSettings: state.updateSettings,
122 ensureComposerDraftsLoaded: () => {},
123 scheduleUnusedComposerAttachmentCleanup: () => {},
124 appendComposerDraftAttachments: vi.fn(),
125 appendComposerDraftText: vi.fn(),
126 mergeComposerDraftContent: vi.fn(),
127 removeComposerDraftAttachment: vi.fn(),
128 useComposerDraft: () => state.draft,
129 };
130});
131vi.mock("../lib/composerImages", () => ({
132 convertPastedImagesToAttachments: vi.fn(),
133 pasteComposerClipboard: vi.fn(),
134 pickComposerFiles: vi.fn(),
135 pickComposerMedia: vi.fn(),
136}));
137vi.mock("../lib/commandMetadata", () => ({
138 makeQueuedMessageMetadata: () => ({
139 messageId: "message",
140 commandId: "command",
141 createdAt: "2026-09-05T00:00:00.000Z",
142 }),
143}));
144vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));
145vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false }));
146vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() }));
147
148import { useThreadComposerState } from "./use-thread-composer-state";
149import { useSelectedThreadRequests } from "./use-selected-thread-requests";
150
151beforeEach(() => {
152 state.grantedEnvironments = new Set(["primary"]);
153 state.connectionState = "connected";
154 state.draft = { text: "Keep my draft", attachments: [] };
155 state.pendingRequests = {
156 approvals: [
157 {
158 requestId: RuntimeRequestId.make("approval"),
159 requestKind: "command",
160 createdAt: "2026-09-05T00:00:00.000Z",
161 responseCapability: "live",
162 },
163 ],
164 userInputs: [],
165 };
166 vi.clearAllMocks();
167 state.approve.mockResolvedValue(AsyncResult.success(undefined));
168 state.answer.mockResolvedValue(AsyncResult.success(undefined));
169 state.feedback.mockResolvedValue(AsyncResult.success({ feedbackId: "feedback" }));
170});
171
172describe("mobile task permissions", () => {
173 it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => {
174 const composer = useThreadComposerState();
175 expect(await composer.onSendMessage()).toBeNull();
176 expect(state.enqueue).not.toHaveBeenCalled();
177 expect(state.clearDraft).not.toHaveBeenCalled();
178 composer.onChangeDraftMessage("Edited locally");
179 composer.onUpdateInteractionMode("plan");
180 expect(state.draft.text).toBe("Edited locally");
181 expect(state.updateSettings).toHaveBeenCalledWith("secondary:thread", {
182 interactionMode: "plan",
183 });
184 });
185
186 it("rechecks a retained send callback before clearing or queueing the draft", async () => {
187 state.grantedEnvironments.add("secondary");
188 const composer = useThreadComposerState();
189 state.grantedEnvironments.delete("secondary");
190 expect(await composer.onSendMessage()).toBeNull();
191 expect(state.clearDraft).not.toHaveBeenCalled();
192 expect(state.enqueue).not.toHaveBeenCalled();
193 state.grantedEnvironments.add("secondary");
194 expect(await composer.onSendMessage()).toBe("message");
195 expect(state.enqueue).toHaveBeenCalledWith(
196 expect.objectContaining({
197 environmentId: "secondary",
198 threadId: "thread",
199 text: "Keep my draft",
200 }),
201 );
202 });
203
204 it("preserves offline queueing without dispatching feedback through the queue action", async () => {
205 state.connectionState = "disconnected";
206 expect(await useThreadComposerState().onSendMessage()).toBe("message");
207 expect(state.enqueue).toHaveBeenCalledTimes(1);
208 state.draft.text = "/feedback The agent stopped early.";
209 expect(await useThreadComposerState().onSendMessage()).toBeNull();
210 expect(state.feedback).not.toHaveBeenCalled();
211 expect(state.enqueue).toHaveBeenCalledTimes(1);
212 expect(state.clearDraft).toHaveBeenCalledTimes(1);
213 });
214
215 it("rechecks the target environment before an approval response", async () => {
216 state.grantedEnvironments.add("secondary");
217 const requests = useSelectedThreadRequests();
218 state.grantedEnvironments.delete("secondary");
219 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
220 expect(state.approve).not.toHaveBeenCalled();
221 state.grantedEnvironments.add("secondary");
222 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
223 expect(state.approve).toHaveBeenCalledWith({
224 environmentId: "secondary",
225 input: { threadId: "thread", requestId: "approval", decision: "accept" },
226 });
227 });
228
229 it("keeps answer drafts editable after revocation while blocking their submission", async () => {
230 state.pendingRequests = {
231 approvals: [],
232 userInputs: [
233 {
234 requestId: RuntimeRequestId.make("input"),
235 createdAt: "2026-09-05T00:00:00.000Z",
236 responseCapability: "live",
237 dismissible: false,
238 questions: [state.question],
239 },
240 ],
241 };
242 const requestId = RuntimeRequestId.make("input");
243 useSelectedThreadRequests().onSelectUserInputOption(requestId, state.question, "TypeScript");
244 state.grantedEnvironments.add("secondary");
245 const requests = useSelectedThreadRequests();
246 expect(requests.activePendingUserInputAnswers).toEqual({ language: "TypeScript" });
247 state.grantedEnvironments.delete("secondary");
248 await requests.onSubmitUserInput();
249 expect(state.answer).not.toHaveBeenCalled();
250 expect(useSelectedThreadRequests().activePendingUserInputAnswers).toEqual({
251 language: "TypeScript",
252 });
253 state.grantedEnvironments.add("secondary");
254 await requests.onSubmitUserInput();
255 expect(state.answer).toHaveBeenCalledWith({
256 environmentId: "secondary",
257 input: { threadId: "thread", requestId: "input", answers: { language: "TypeScript" } },
258 });
259 });
260});
apps/mobile/src/state/use-selected-thread-requests.test.tsx+1
...@@ -47,6 +47,7 @@ vi.mock("./entities", () => ({...@@ -47,6 +47,7 @@ vi.mock("./entities", () => ({
47 ],47 ],
48 ]),48 ]),
49}));49}));
50vi.mock("./session", () => ({ readEnvironmentScope: () => true }));
50vi.mock("./threads", () => ({ threadEnvironment: {} }));51vi.mock("./threads", () => ({ threadEnvironment: {} }));
51vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));52vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
52vi.mock("./use-thread-selection", () => ({53vi.mock("./use-thread-selection", () => ({
apps/mobile/src/state/use-selected-thread-requests.ts+13-3
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type PendingThreadRequests,19 type PendingThreadRequests,
20 type ThreadUserInputQuestion,20 type ThreadUserInputQuestion,
21} from "@t3tools/client-runtime/state/thread-requests";21} from "@t3tools/client-runtime/state/thread-requests";
22import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
22import { Atom } from "effect/reactivity";23import { Atom } from "effect/reactivity";
2324
24import { threadEnvironment } from "../state/threads";25import { threadEnvironment } from "../state/threads";
...@@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry";...@@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry";
33import { useSelectedThreadPendingRequests } from "./use-thread-detail";34import { useSelectedThreadPendingRequests } from "./use-thread-detail";
34import { useThreadSelection } from "./use-thread-selection";35import { useThreadSelection } from "./use-thread-selection";
35import { useAtomCommand } from "./use-atom-command";36import { useAtomCommand } from "./use-atom-command";
37import { readEnvironmentScope } from "./session";
3638
37const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] };39const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] };
3840
...@@ -211,7 +213,10 @@ export function useSelectedThreadRequests() {...@@ -211,7 +213,10 @@ export function useSelectedThreadRequests() {
211213
212 const onRespondToApproval = useCallback(214 const onRespondToApproval = useCallback(
213 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {215 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
214 if (!selectedThreadShell) {216 if (
217 !selectedThreadShell ||
218 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
219 ) {
215 return;220 return;
216 }221 }
217 if (222 if (
...@@ -241,7 +246,8 @@ export function useSelectedThreadRequests() {...@@ -241,7 +246,8 @@ export function useSelectedThreadRequests() {
241 !selectedThreadShell ||246 !selectedThreadShell ||
242 !activePendingUserInput ||247 !activePendingUserInput ||
243 activePendingUserInput.responseCapability === "not_resumable" ||248 activePendingUserInput.responseCapability === "not_resumable" ||
244 !activePendingUserInputAnswers249 !activePendingUserInputAnswers ||
250 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
245 ) {251 ) {
246 return;252 return;
247 }253 }
...@@ -318,7 +324,11 @@ export function useSelectedThreadRequests() {...@@ -318,7 +324,11 @@ export function useSelectedThreadRequests() {
318324
319 // Closes an async question without messaging the agent.325 // Closes an async question without messaging the agent.
320 const onDismissUserInput = useCallback(async () => {326 const onDismissUserInput = useCallback(async () => {
321 if (!selectedThreadShell || !activePendingUserInput) {327 if (
328 !selectedThreadShell ||
329 !activePendingUserInput ||
330 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
331 ) {
322 return;332 return;
323 }333 }
324334
apps/mobile/src/state/use-thread-composer-state.ts+10
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "./session";
1import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";3import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
2import { useAtomValue } from "@effect/atom-react";4import { useAtomValue } from "@effect/atom-react";
3import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";5import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
...@@ -548,6 +550,12 @@ export function useThreadComposerState() {...@@ -548,6 +550,12 @@ export function useThreadComposerState() {
548550
549 const onSendMessage = useCallback(551 const onSendMessage = useCallback(
550 async (followUpOverride?: ActiveTurnComposerAction) => {552 async (followUpOverride?: ActiveTurnComposerAction) => {
553 if (
554 selectedThreadShell &&
555 selectedEnvironmentRuntime?.connectionState === "connected" &&
556 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
557 )
558 return null;
551 if (!selectedThreadShell) {559 if (!selectedThreadShell) {
552 return null;560 return null;
553 }561 }
...@@ -624,6 +632,8 @@ export function useThreadComposerState() {...@@ -624,6 +632,8 @@ export function useThreadComposerState() {
624 ? parseCodexFeedbackCommand(text)632 ? parseCodexFeedbackCommand(text)
625 : null;633 : null;
626 if (feedbackCommand) {634 if (feedbackCommand) {
635 if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope))
636 return null;
627 if (thread.activeProviderThreadId === null) {637 if (thread.activeProviderThreadId === null) {
628 Alert.alert("Start a Codex thread first", "Send a message before you submit feedback.");638 Alert.alert("Start a Codex thread first", "Send a message before you submit feedback.");
629 return null;639 return null;
apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts created+318
...@@ -0,0 +1,318 @@
1import {
2 AuthOrchestrationOperateScope,
3 CommandId,
4 EnvironmentId,
5 MessageId,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { AsyncResult, type Atom } from "effect/reactivity";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
13import type { QueuedThreadMessage } from "./thread-outbox-model";
14
15const state = vi.hoisted(() => ({
16 grantedEnvironments: new Set<string>(),
17 draftText: "Keep this draft separate",
18 cleanups: [] as Array<() => void>,
19 start: vi.fn(),
20 metadata: vi.fn(),
21 runtime: vi.fn(),
22 interaction: vi.fn(),
23 prepare: vi.fn(),
24 manager: null as unknown as ReturnType<
25 typeof import("./thread-outbox-manager").createThreadOutboxManager
26 >,
27 thread: {
28 environmentId: "secondary",
29 id: "thread",
30 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
31 runtimeMode: "full-access",
32 interactionMode: "default",
33 session: null,
34 },
35 config: {
36 providers: [{ instanceId: "codex", driver: "codex" }],
37 environment: { capabilities: {} },
38 },
39}));
40vi.mock("react", () => ({
41 useCallback: <A>(callback: A) => callback,
42 useEffect: (effect: () => void | (() => void)) => {
43 const cleanup = effect();
44 if (cleanup) state.cleanups.push(cleanup);
45 },
46 useRef: <A>(value: A) => ({ current: value }),
47 useState: <A>(initial: A) => [initial, vi.fn()],
48}));
49vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
50vi.mock("@effect/atom-react", async () => {
51 const { appAtomRegistry } = await import("./atom-registry");
52 return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) };
53});
54vi.mock("./session", () => ({
55 readEnvironmentScope: (environmentId: string, scope: string) =>
56 scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId),
57 useEnvironmentsWithScope: () => state.grantedEnvironments,
58}));
59vi.mock("./entities", () => ({
60 useProjects: () => [],
61 useThreadShells: () => [state.thread],
62 useServerConfigs: () => new Map([["secondary", state.config]]),
63}));
64vi.mock("./server", async () => {
65 const { Atom } = await import("effect/reactivity");
66 return {
67 serverEnvironment: { configValueAtom: Atom.family((_id: string) => Atom.make(state.config)) },
68 };
69});
70vi.mock("./threads", async () => {
71 const { Atom } = await import("effect/reactivity");
72 return {
73 threadEnvironment: {
74 startTurn: state.start,
75 updateMetadata: state.metadata,
76 setRuntimeMode: state.runtime,
77 setInteractionMode: state.interaction,
78 },
79 environmentThreadShells: { threadShellsAtom: Atom.make([state.thread]) },
80 };
81});
82vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command }));
83vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false }));
84vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" }));
85vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare }));
86vi.mock("./use-remote-environment-registry", () => ({
87 setPendingConnectionError: vi.fn(),
88 useRemoteConnectionStatus: () => ({
89 connectedEnvironments: [{ environmentId: "secondary", connectionState: "connected" }],
90 }),
91}));
92vi.mock("./thread-outbox", async () => {
93 const { createThreadOutboxManager } = await import("./thread-outbox-manager");
94 const { appAtomRegistry } = await import("./atom-registry");
95 state.manager = createThreadOutboxManager({
96 registry: appAtomRegistry,
97 storage: {
98 load: async () => ({ messages: [], errors: [] }),
99 write: async () => {},
100 remove: async () => {},
101 },
102 });
103 return {
104 threadOutboxManager: state.manager,
105 threadOutboxRevision: (id: MessageId) => state.manager.revisionOf(id),
106 confirmThreadOutboxMessageQueued: (message: QueuedThreadMessage) =>
107 state.manager.confirmQueued(message),
108 updateThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) =>
109 state.manager.update(message, revision),
110 };
111});
112vi.mock("./thread-outbox-removal", () => ({
113 removeThreadOutboxMessage: (message: QueuedThreadMessage, revision?: number) =>
114 state.manager.remove(message, revision),
115}));
116vi.mock("./use-thread-outbox", async () => {
117 const { Atom } = await import("effect/reactivity");
118 const { appAtomRegistry } = await import("./atom-registry");
119 await import("./thread-outbox");
120 return {
121 editingQueuedMessageIdsAtom: Atom.make({}).pipe(Atom.keepAlive),
122 dispatchingQueuedMessageIdAtom: Atom.make<MessageId | null>(null).pipe(Atom.keepAlive),
123 useThreadOutboxMessages: () => appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom),
124 useThreadOutboxShellStatuses: () => new Map([["secondary", "live"]]),
125 };
126});
127vi.mock("./use-composer-drafts", async () => {
128 const { Atom } = await import("effect/reactivity");
129 return {
130 composerDraftsAtom: Atom.make({}),
131 removeDeliveredCloudQueuedMessage: async () => {},
132 appendComposerDraftAttachments: vi.fn(),
133 flushComposerDrafts: vi.fn(),
134 getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })),
135 mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => {
136 state.draftText += `\n${input.text}`;
137 }),
138 replaceComposerDraftAttachments: vi.fn(),
139 undoComposerDraftMerge: vi.fn(),
140 updateComposerDraftSettings: vi.fn(),
141 waitForComposerDraftsLoaded: vi.fn(),
142 };
143});
144
145import { appAtomRegistry } from "./atom-registry";
146import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox";
147import { useThreadOutboxDrain } from "./use-thread-outbox-drain";
148
149const message = (overrides: Partial<QueuedThreadMessage> = {}): QueuedThreadMessage => ({
150 environmentId: EnvironmentId.make("secondary"),
151 threadId: ThreadId.make("thread"),
152 commandId: CommandId.make("command"),
153 messageId: MessageId.make("message"),
154 text: "Keep this queued",
155 attachments: [],
156 createdAt: "2026-09-05T00:00:00.000Z",
157 ...overrides,
158});
159const remaining = () =>
160 Object.values(appAtomRegistry.get(state.manager.queuedMessagesByThreadKeyAtom)).flat();
161
162function runDrain() {
163 const settled = Promise.withResolvers<void>();
164 const release = appAtomRegistry.subscribe(dispatchingQueuedMessageIdAtom, (id) => {
165 if (id === null) settled.resolve();
166 });
167 useThreadOutboxDrain();
168 return settled.promise.finally(release);
169}
170
171beforeEach(() => {
172 state.grantedEnvironments = new Set(["primary"]);
173 state.draftText = "Keep this draft separate";
174 appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {});
175 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null);
176 for (const command of [state.start, state.metadata, state.runtime, state.interaction]) {
177 command.mockReset().mockResolvedValue(AsyncResult.success(undefined));
178 }
179 state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({
180 status: "ready",
181 attachments: [],
182 draftAttachments: attachments,
183 pendingAttachmentIds: [],
184 }));
185});
186afterEach(() => {
187 state.cleanups.splice(0).forEach((cleanup) => cleanup());
188});
189
190describe("queued task operation access", () => {
191 it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => {
192 const queued = message();
193 await state.manager.enqueue(queued);
194 useThreadOutboxDrain();
195 expect(state.start).not.toHaveBeenCalled();
196 expect(state.prepare).not.toHaveBeenCalled();
197 expect(remaining()).toEqual([queued]);
198 state.grantedEnvironments.add("secondary");
199 await runDrain();
200 expect(state.start).toHaveBeenCalledOnce();
201 expect(remaining()).toEqual([]);
202 });
203
204 it("stops the remaining settings and turn commands when an earlier update loses access", async () => {
205 state.grantedEnvironments.add("secondary");
206 const queued = message({
207 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "different-model" },
208 runtimeMode: "approval-required",
209 interactionMode: "plan",
210 });
211 await state.manager.enqueue(queued);
212 state.runtime.mockImplementationOnce(async () => {
213 state.grantedEnvironments.delete("secondary");
214 return AsyncResult.success(undefined);
215 });
216 await runDrain();
217 expect(state.runtime).toHaveBeenCalledOnce();
218 expect(state.interaction).not.toHaveBeenCalled();
219 expect(state.prepare).not.toHaveBeenCalled();
220 expect(state.start).not.toHaveBeenCalled();
221 expect(remaining()).toEqual([queued]);
222 });
223
224 it.each([
225 { isCreation: false, accepted: false },
226 { isCreation: true, accepted: false },
227 { isCreation: false, accepted: true },
228 { isCreation: true, accepted: true },
229 ])(
230 "preserves a rejected queue but removes an accepted turn after access loss (new task: $isCreation, accepted: $accepted)",
231 async ({ isCreation, accepted }) => {
232 state.grantedEnvironments.add("secondary");
233 const queued = message(
234 isCreation
235 ? {
236 threadId: ThreadId.make("new-thread"),
237 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
238 creation: {
239 projectId: ProjectId.make("project"),
240 projectCwd: "/repo",
241 workspaceMode: "local",
242 branch: null,
243 worktreePath: null,
244 },
245 }
246 : {},
247 );
248 await state.manager.enqueue(queued);
249 const started = Promise.withResolvers<void>();
250 const delivery = Promise.withResolvers<AsyncResult.AsyncResult<void, Error>>();
251 state.start.mockImplementationOnce(() => {
252 started.resolve();
253 return delivery.promise;
254 });
255 const drained = runDrain();
256 await started.promise;
257 state.grantedEnvironments.delete("secondary");
258 delivery.resolve(
259 accepted
260 ? AsyncResult.success(undefined)
261 : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))),
262 );
263 await drained;
264
265 expect(remaining()).toEqual(accepted ? [] : [queued]);
266 expect(state.draftText).toBe("Keep this draft separate");
267 if (!accepted) {
268 state.grantedEnvironments.add("secondary");
269 await runDrain();
270 expect(state.start).toHaveBeenCalledTimes(2);
271 expect(remaining()).toEqual([]);
272 expect(state.draftText).toBe("Keep this draft separate");
273 }
274 },
275 );
276
277 it.each([
278 { isCreation: false, uploadFails: false },
279 { isCreation: true, uploadFails: false },
280 { isCreation: false, uploadFails: true },
281 { isCreation: true, uploadFails: true },
282 ])(
283 "keeps the message queued after upload revocation (new task: $isCreation, upload fails: $uploadFails)",
284 async ({ isCreation, uploadFails }) => {
285 state.grantedEnvironments.add("secondary");
286 const queued = message(
287 isCreation
288 ? {
289 threadId: ThreadId.make("new-thread"),
290 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
291 creation: {
292 projectId: ProjectId.make("project"),
293 projectCwd: "/repo",
294 workspaceMode: "local",
295 branch: null,
296 worktreePath: null,
297 },
298 }
299 : {},
300 );
301 await state.manager.enqueue(queued);
302 state.prepare.mockImplementationOnce(async ({ attachments }) => {
303 state.grantedEnvironments.delete("secondary");
304 if (uploadFails) throw new Error("This connection cannot upload attachments.");
305 return {
306 status: "ready",
307 attachments: [],
308 draftAttachments: attachments,
309 pendingAttachmentIds: [],
310 };
311 });
312 await runDrain();
313 expect(state.prepare).toHaveBeenCalledOnce();
314 expect(state.start).not.toHaveBeenCalled();
315 expect(remaining()).toEqual([queued]);
316 },
317 );
318});
apps/mobile/src/state/use-thread-outbox-drain.test.ts+28
...@@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"...@@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
13import type { PreparedTurnAttachments } from "../lib/attachmentUpload";13import type { PreparedTurnAttachments } from "../lib/attachmentUpload";
1414
15const harness = vi.hoisted(() => ({15const harness = vi.hoisted(() => ({
16 canOperate: true,
16 manager: null as unknown as ReturnType<17 manager: null as unknown as ReturnType<
17 typeof import("./thread-outbox-manager").createThreadOutboxManager18 typeof import("./thread-outbox-manager").createThreadOutboxManager
18 >,19 >,
...@@ -95,6 +96,11 @@ vi.mock("./use-atom-command", () => ({...@@ -95,6 +96,11 @@ vi.mock("./use-atom-command", () => ({
95 useAtomCommand: () => async () => undefined,96 useAtomCommand: () => async () => undefined,
96}));97}));
9798
99vi.mock("./session", () => ({
100 readEnvironmentScope: () => harness.canOperate,
101 useEnvironmentsWithScope: () => new Set(),
102}));
103
98vi.mock("./use-thread-outbox", async () => {104vi.mock("./use-thread-outbox", async () => {
99 const { Atom } = await import("effect/reactivity");105 const { Atom } = await import("effect/reactivity");
100 return {106 return {
...@@ -203,6 +209,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {...@@ -203,6 +209,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {
203209
204beforeEach(() => {210beforeEach(() => {
205 appAtomRegistry.set(acknowledgedThreadMessagesAtom, []);211 appAtomRegistry.set(acknowledgedThreadMessagesAtom, []);
212 harness.canOperate = true;
206 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });213 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });
207});214});
208215
...@@ -220,6 +227,27 @@ afterEach(() => {...@@ -220,6 +227,27 @@ afterEach(() => {
220});227});
221228
222describe("thread outbox attachment preparation", () => {229describe("thread outbox attachment preparation", () => {
230 it("keeps a denied message queued without starting its attachment upload", async () => {
231 const message = queuedMessage({
232 messageId: "denied-queued-message",
233 text: "Keep this queued",
234 fileUri: "file:///documents/keep.pdf",
235 });
236 await harness.manager.enqueue(message);
237 harness.prepareTurnAttachments.mockResolvedValueOnce({
238 status: "ready",
239 attachments: [],
240 draftAttachments: message.attachments,
241 pendingAttachmentIds: [],
242 });
243 harness.canOperate = false;
244 await expect(prepareQueuedMessageAttachments(message)).resolves.toEqual({
245 status: "abandoned",
246 });
247 expect(harness.prepareTurnAttachments).not.toHaveBeenCalled();
248 expect(remainingMessages()).toEqual([message]);
249 });
250
223 it("abandons reused uploads when an editor saves changed text during verification", async () => {251 it("abandons reused uploads when an editor saves changed text during verification", async () => {
224 const message = withReusedFileUpload(252 const message = withReusedFileUpload(
225 queuedMessage({253 queuedMessage({
apps/mobile/src/state/use-thread-outbox-drain.ts+28
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";7import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
8import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";8import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
9import {9import {
10 AuthOrchestrationOperateScope,
10 CommandId,11 CommandId,
11 DEFAULT_PROVIDER_INTERACTION_MODE,12 DEFAULT_PROVIDER_INTERACTION_MODE,
12 DEFAULT_RUNTIME_MODE,13 DEFAULT_RUNTIME_MODE,
...@@ -39,6 +40,7 @@ import {...@@ -39,6 +40,7 @@ import {
39 recordPendingThreadCreationOutcome,40 recordPendingThreadCreationOutcome,
40} from "./pending-thread-creation";41} from "./pending-thread-creation";
41import { serverEnvironment } from "./server";42import { serverEnvironment } from "./server";
43import { readEnvironmentScope, useEnvironmentsWithScope } from "./session";
42import {44import {
43 confirmThreadOutboxMessageQueued,45 confirmThreadOutboxMessageQueued,
44 threadOutboxManager,46 threadOutboxManager,
...@@ -226,6 +228,9 @@ export async function prepareQueuedMessageAttachments(...@@ -226,6 +228,9 @@ export async function prepareQueuedMessageAttachments(
226 if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) {228 if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) {
227 return { status: "abandoned" };229 return { status: "abandoned" };
228 }230 }
231 if (!readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope)) {
232 return { status: "abandoned" };
233 }
229 const revision = threadOutboxRevision(queuedMessage.messageId);234 const revision = threadOutboxRevision(queuedMessage.messageId);
230 if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) {235 if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) {
231 return { status: "abandoned" };236 return { status: "abandoned" };
...@@ -668,6 +673,10 @@ export function useThreadOutboxDrain(): void {...@@ -668,6 +673,10 @@ export function useThreadOutboxDrain(): void {
668 const projects = useProjects();673 const projects = useProjects();
669 const serverConfigs = useServerConfigs();674 const serverConfigs = useServerConfigs();
670 const { connectedEnvironments } = useRemoteConnectionStatus();675 const { connectedEnvironments } = useRemoteConnectionStatus();
676 const operableEnvironments = useEnvironmentsWithScope(
677 connectedEnvironments,
678 AuthOrchestrationOperateScope,
679 );
671 const [retryTick, setRetryTick] = useState(0);680 const [retryTick, setRetryTick] = useState(0);
672 const retryAttemptRef = useRef(new Map<MessageId, number>());681 const retryAttemptRef = useRef(new Map<MessageId, number>());
673 const retryNotBeforeRef = useRef(new Map<MessageId, number>());682 const retryNotBeforeRef = useRef(new Map<MessageId, number>());
...@@ -794,6 +803,9 @@ export function useThreadOutboxDrain(): void {...@@ -794,6 +803,9 @@ export function useThreadOutboxDrain(): void {
794803
795 const sendQueuedMessage = useCallback(804 const sendQueuedMessage = useCallback(
796 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {805 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
806 const hasAccess = () =>
807 readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope);
808 if (!hasAccess()) return true;
797 const serverConfig = appAtomRegistry.get(809 const serverConfig = appAtomRegistry.get(
798 serverEnvironment.configValueAtom(queuedMessage.environmentId),810 serverEnvironment.configValueAtom(queuedMessage.environmentId),
799 );811 );
...@@ -808,6 +820,7 @@ export function useThreadOutboxDrain(): void {...@@ -808,6 +820,7 @@ export function useThreadOutboxDrain(): void {
808 const { reportFailure } = makeDeliveryHelpers(queuedMessage);820 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
809821
810 if (settings.runtimeMode !== thread.runtimeMode) {822 if (settings.runtimeMode !== thread.runtimeMode) {
823 if (!hasAccess()) return true;
811 const runtimeResult = await setThreadRuntimeMode({824 const runtimeResult = await setThreadRuntimeMode({
812 environmentId: queuedMessage.environmentId,825 environmentId: queuedMessage.environmentId,
813 input: {826 input: {
...@@ -824,6 +837,7 @@ export function useThreadOutboxDrain(): void {...@@ -824,6 +837,7 @@ export function useThreadOutboxDrain(): void {
824 }837 }
825838
826 if (settings.interactionMode !== thread.interactionMode) {839 if (settings.interactionMode !== thread.interactionMode) {
840 if (!hasAccess()) return true;
827 const interactionResult = await setThreadInteractionMode({841 const interactionResult = await setThreadInteractionMode({
828 environmentId: queuedMessage.environmentId,842 environmentId: queuedMessage.environmentId,
829 input: {843 input: {
...@@ -842,6 +856,7 @@ export function useThreadOutboxDrain(): void {...@@ -842,6 +856,7 @@ export function useThreadOutboxDrain(): void {
842 let prepared: PreparedTurnAttachments;856 let prepared: PreparedTurnAttachments;
843 let persistedMessage: QueuedThreadMessage;857 let persistedMessage: QueuedThreadMessage;
844 let deliveryRevision: number;858 let deliveryRevision: number;
859 if (!hasAccess()) return true;
845 try {860 try {
846 const preparedResult = await prepareQueuedMessageAttachments(861 const preparedResult = await prepareQueuedMessageAttachments(
847 queuedMessage,862 queuedMessage,
...@@ -861,6 +876,7 @@ export function useThreadOutboxDrain(): void {...@@ -861,6 +876,7 @@ export function useThreadOutboxDrain(): void {
861 return true;876 return true;
862 }877 }
863 } catch (error) {878 } catch (error) {
879 if (!hasAccess()) return true;
864 logThreadOutboxUploadFailure(queuedMessage, error);880 logThreadOutboxUploadFailure(queuedMessage, error);
865 if (!shouldRetryThreadOutboxDelivery(error)) {881 if (!shouldRetryThreadOutboxDelivery(error)) {
866 return restoreQueuedMessage(882 return restoreQueuedMessage(
...@@ -888,6 +904,7 @@ export function useThreadOutboxDrain(): void {...@@ -888,6 +904,7 @@ export function useThreadOutboxDrain(): void {
888 settings,904 settings,
889 currentConfig.providers,905 currentConfig.providers,
890 );906 );
907 if (!hasAccess()) return true;
891 const deliveryResult = await startTurn({908 const deliveryResult = await startTurn({
892 environmentId: queuedMessage.environmentId,909 environmentId: queuedMessage.environmentId,
893 input: {910 input: {
...@@ -922,6 +939,7 @@ export function useThreadOutboxDrain(): void {...@@ -922,6 +939,7 @@ export function useThreadOutboxDrain(): void {
922 dispatchMode: queuedMessage.dispatchMode ?? "start",939 dispatchMode: queuedMessage.dispatchMode ?? "start",
923 },940 },
924 });941 });
942 if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
925 const failure = reportFailure(deliveryResult, "start-turn");943 const failure = reportFailure(deliveryResult, "start-turn");
926 if (failure?.action === "retry") {944 if (failure?.action === "retry") {
927 return false;945 return false;
...@@ -952,6 +970,9 @@ export function useThreadOutboxDrain(): void {...@@ -952,6 +970,9 @@ export function useThreadOutboxDrain(): void {
952 creation: QueuedThreadCreation,970 creation: QueuedThreadCreation,
953 projectCwd: string,971 projectCwd: string,
954 ) => {972 ) => {
973 const hasAccess = () =>
974 readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope);
975 if (!hasAccess()) return true;
955 const modelSelection = queuedMessage.modelSelection;976 const modelSelection = queuedMessage.modelSelection;
956 if (modelSelection === undefined) {977 if (modelSelection === undefined) {
957 return false;978 return false;
...@@ -997,6 +1018,7 @@ export function useThreadOutboxDrain(): void {...@@ -997,6 +1018,7 @@ export function useThreadOutboxDrain(): void {
997 return true;1018 return true;
998 }1019 }
999 } catch (error) {1020 } catch (error) {
1021 if (!hasAccess()) return true;
1000 logThreadOutboxUploadFailure(queuedMessage, error);1022 logThreadOutboxUploadFailure(queuedMessage, error);
1001 if (!shouldRetryThreadOutboxDelivery(error)) {1023 if (!shouldRetryThreadOutboxDelivery(error)) {
1002 return restoreQueuedMessage(1024 return restoreQueuedMessage(
...@@ -1024,6 +1046,7 @@ export function useThreadOutboxDrain(): void {...@@ -1024,6 +1046,7 @@ export function useThreadOutboxDrain(): void {
1024 settings,1046 settings,
1025 currentConfig.providers,1047 currentConfig.providers,
1026 );1048 );
1049 if (!hasAccess()) return true;
1027 const deliveryResult = await startTurn({1050 const deliveryResult = await startTurn({
1028 environmentId: queuedMessage.environmentId,1051 environmentId: queuedMessage.environmentId,
1029 input: buildProjectThreadStartTurnInput({1052 input: buildProjectThreadStartTurnInput({
...@@ -1054,6 +1077,7 @@ export function useThreadOutboxDrain(): void {...@@ -1054,6 +1077,7 @@ export function useThreadOutboxDrain(): void {
1054 }),1077 }),
1055 });1078 });
1056 const { reportFailure } = makeDeliveryHelpers(queuedMessage);1079 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
1080 if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
1057 const failure = reportFailure(deliveryResult, "start-turn");1081 const failure = reportFailure(deliveryResult, "start-turn");
1058 if (failure?.action === "retry") {1082 if (failure?.action === "retry") {
1059 return false;1083 return false;
...@@ -1188,6 +1212,9 @@ export function useThreadOutboxDrain(): void {...@@ -1188,6 +1212,9 @@ export function useThreadOutboxDrain(): void {
1188 environmentConnected: environment?.connectionState === "connected",1212 environmentConnected: environment?.connectionState === "connected",
1189 threadBusy: threadRuntimeIsActive(thread?.runtime),1213 threadBusy: threadRuntimeIsActive(thread?.runtime),
1190 });1214 });
1215 if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) {
1216 continue;
1217 }
1191 // The delivery action resolves first; capability checks apply only to1218 // The delivery action resolves first; capability checks apply only to
1192 // a message that will send. Checking earlier would restore a1219 // a message that will send. Checking earlier would restore a
1193 // creation whose startTurn already made the thread as a duplicate draft1220 // creation whose startTurn already made the thread as a duplicate draft
...@@ -1345,6 +1372,7 @@ export function useThreadOutboxDrain(): void {...@@ -1345,6 +1372,7 @@ export function useThreadOutboxDrain(): void {
1345 connectedEnvironments,1372 connectedEnvironments,
1346 dispatchingQueuedMessageId,1373 dispatchingQueuedMessageId,
1347 editingQueuedMessageIds,1374 editingQueuedMessageIds,
1375 operableEnvironments,
1348 projects,1376 projects,
1349 queuedMessagesByThreadKey,1377 queuedMessagesByThreadKey,
1350 retryTick,1378 retryTick,
apps/server/src/auth/EnvironmentAuth.test.ts+3-18
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes } from "@t3tools/contracts";2import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
...@@ -328,13 +328,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -328,13 +328,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
328 );328 );
329329
330 expect(verified.sessionId.length).toBeGreaterThan(0);330 expect(verified.sessionId.length).toBeGreaterThan(0);
331 expect(verified.scopes).toEqual([331 expect(verified.scopes).toEqual(AuthStandardClientScopes);
332 "orchestration:read",
333 "orchestration:operate",
334 "terminal:operate",
335 "review:write",
336 "relay:read",
337 ]);
338 expect(verified.subject).toBe("one-time-token");332 expect(verified.subject).toBe("one-time-token");
339 }).pipe(Effect.provide(layerEnvironmentAuth())),333 }).pipe(Effect.provide(layerEnvironmentAuth())),
340 );334 );
...@@ -535,16 +529,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {...@@ -535,16 +529,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
535 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),529 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
536 );530 );
537531
538 expect(verified.scopes).toEqual([532 expect(verified.scopes).toEqual(AuthAdministrativeScopes);
539 "orchestration:read",
540 "orchestration:operate",
541 "terminal:operate",
542 "review:write",
543 "relay:read",
544 "access:read",
545 "access:write",
546 "relay:write",
547 ]);
548 expect(verified.subject).toBe("administrative-bootstrap");533 expect(verified.subject).toBe("administrative-bootstrap");
549 }).pipe(Effect.provide(layerEnvironmentAuth())),534 }).pipe(Effect.provide(layerEnvironmentAuth())),
550 );535 );
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+3-20
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { AuthAdministrativeScopes } from "@t3tools/contracts";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =...@@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =
79 const listedAfterRevoke = yield* environmentAuth.listSessions();80 const listedAfterRevoke = yield* environmentAuth.listSessions();
8081
81 expect(issued.method).toBe("bearer-access-token");82 expect(issued.method).toBe("bearer-access-token");
82 expect(issued.scopes).toEqual([83 expect(issued.scopes).toEqual(AuthAdministrativeScopes);
83 "orchestration:read",
84 "orchestration:operate",
85 "terminal:operate",
86 "review:write",
87 "relay:read",
88 "access:read",
89 "access:write",
90 "relay:write",
91 ]);
92 expect(issued.client.deviceType).toBe("bot");84 expect(issued.client.deviceType).toBe("bot");
93 expect(issued.client.label).toBe("deploy-bot");85 expect(issued.client.label).toBe("deploy-bot");
94 expect(verified.sessionId).toBe(issued.sessionId);86 expect(verified.sessionId).toBe(issued.sessionId);
95 expect(verified.scopes).toEqual([87 expect(verified.scopes).toEqual(AuthAdministrativeScopes);
96 "orchestration:read",
97 "orchestration:operate",
98 "terminal:operate",
99 "review:write",
100 "relay:read",
101 "access:read",
102 "access:write",
103 "relay:write",
104 ]);
105 expect(verified.method).toBe("bearer-access-token");88 expect(verified.method).toBe("bearer-access-token");
106 expect(listedBeforeRevoke).toHaveLength(1);89 expect(listedBeforeRevoke).toHaveLength(1);
107 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);90 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+2
...@@ -34,6 +34,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -34,6 +34,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
3434
35 expect(descriptor.policy).toBe("desktop-managed-local");35 expect(descriptor.policy).toBe("desktop-managed-local");
36 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);36 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
37 expect(descriptor.serverUpdateScope).toBe("environment:maintain");
37 // Packaged desktop has no devUrl, but still needs the port scope: it38 // Packaged desktop has no devUrl, but still needs the port scope: it
38 // scans upward from 3773 for a free port and binds 127.0.0.1, so a second39 // scans upward from 3773 for a free port and binds 127.0.0.1, so a second
39 // instance shares this one's hostname on a different port.40 // instance shares this one's hostname on a different port.
...@@ -88,6 +89,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {...@@ -88,6 +89,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
8889
89 expect(descriptor.policy).toBe("loopback-browser");90 expect(descriptor.policy).toBe("loopback-browser");
90 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);91 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
92 expect(descriptor.serverUpdateScope).toBe("environment:maintain");
91 expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/);93 expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/);
92 }).pipe(94 }).pipe(
93 Effect.provide(95 Effect.provide(
apps/server/src/auth/EnvironmentAuthPolicy.ts+2-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";1import { AuthEnvironmentMaintainScope, type ServerAuthDescriptor } from "@t3tools/contracts";
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -40,6 +40,7 @@ export const make = Effect.gen(function* () {...@@ -40,6 +40,7 @@ export const make = Effect.gen(function* () {
40 policy,40 policy,
41 bootstrapMethods,41 bootstrapMethods,
42 sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],42 sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
43 serverUpdateScope: AuthEnvironmentMaintainScope,
43 sessionCookieName: resolveSessionCookieName({44 sessionCookieName: resolveSessionCookieName({
44 mode: config.mode,45 mode: config.mode,
45 port: config.port,46 port: config.port,
apps/server/src/auth/McpOAuth.test.ts+2-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {2import {
3 AuthAdministrativeScopes,3 AuthAdministrativeScopes,
4 type AuthEnvironmentScope,4 type AuthCreatePairingCredentialInput,
5 EnvironmentHttpApi,5 EnvironmentHttpApi,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { expect, it } from "@effect/vitest";7import { expect, it } from "@effect/vitest";
...@@ -418,7 +418,7 @@ it.live("one-click approves only access the browser session holds the scopes for...@@ -418,7 +418,7 @@ it.live("one-click approves only access the browser session holds the scopes for
418 const clientId = yield* registeredClientId(handler);418 const clientId = yield* registeredClientId(handler);
419 const params = authorizeParams(clientId);419 const params = authorizeParams(clientId);
420 // Signs a browser in through the real route and returns its session cookie.420 // Signs a browser in through the real route and returns its session cookie.
421 const browserCookie = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>421 const browserCookie = (scopes: NonNullable<AuthCreatePairingCredentialInput["scopes"]>) =>
422 Effect.gen(function* () {422 Effect.gen(function* () {
423 const pairing = yield* auth.issuePairingCredential({ scopes });423 const pairing = yield* auth.issuePairingCredential({ scopes });
424 const response = yield* handler(424 const response = yield* handler(
apps/server/src/auth/PairingGrantStore.test.ts+3-17
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
...@@ -83,13 +84,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -83,13 +84,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
83 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));84 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
8485
85 expect(first.method).toBe("one-time-token");86 expect(first.method).toBe("one-time-token");
86 expect(first.scopes).toEqual([87 expect(first.scopes).toEqual(AuthStandardClientScopes);
87 "orchestration:read",
88 "orchestration:operate",
89 "terminal:operate",
90 "review:write",
91 "relay:read",
92 ]);
93 expect(first.subject).toBe("one-time-token");88 expect(first.subject).toBe("one-time-token");
94 expect(first.label).toBe("Julius iPhone");89 expect(first.label).toBe("Julius iPhone");
95 expect(issued.label).toBe("Julius iPhone");90 expect(issued.label).toBe("Julius iPhone");
...@@ -168,16 +163,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -168,16 +163,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
168 const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token");163 const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
169164
170 expect(first.method).toBe("desktop-bootstrap");165 expect(first.method).toBe("desktop-bootstrap");
171 expect(first.scopes).toEqual([166 expect(first.scopes).toEqual(AuthAdministrativeScopes);
172 "orchestration:read",
173 "orchestration:operate",
174 "terminal:operate",
175 "review:write",
176 "relay:read",
177 "access:read",
178 "access:write",
179 "relay:write",
180 ]);
181 expect(first.subject).toBe("desktop-bootstrap");167 expect(first.subject).toBe("desktop-bootstrap");
182 expect(second.method).toBe("desktop-bootstrap");168 expect(second.method).toBe("desktop-bootstrap");
183 expect(third.method).toBe("desktop-bootstrap");169 expect(third.method).toBe("desktop-bootstrap");
apps/server/src/auth/RpcAuthorization.test.ts+80-6
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import {1import {
2 AuthEnvironmentMaintainScope,
3 AuthProvidersManageScope,
4 AuthSettingsWriteScope,
5 DEFAULT_SERVER_SETTINGS,
6 ProviderInstanceId,
2 AuthOrchestrationOperateScope,7 AuthOrchestrationOperateScope,
3 AuthOrchestrationReadScope,8 AuthOrchestrationReadScope,
4 AuthRelayReadScope,9 AuthRelayReadScope,
...@@ -28,7 +33,7 @@ describe("RPC authorization scopes", () => {...@@ -28,7 +33,7 @@ describe("RPC authorization scopes", () => {
28 AuthOrchestrationReadScope,33 AuthOrchestrationReadScope,
29 );34 );
30 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe(35 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe(
31 AuthOrchestrationOperateScope,36 AuthEnvironmentMaintainScope,
32 );37 );
33 expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe(38 expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe(
34 AuthOrchestrationReadScope,39 AuthOrchestrationReadScope,
...@@ -75,13 +80,13 @@ describe("RPC authorization scopes", () => {...@@ -75,13 +80,13 @@ describe("RPC authorization scopes", () => {
75 AuthOrchestrationReadScope,80 AuthOrchestrationReadScope,
76 );81 );
77 expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe(82 expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe(
78 AuthOrchestrationOperateScope,83 AuthProvidersManageScope,
79 );84 );
80 expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe(85 expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe(
81 AuthOrchestrationOperateScope,86 AuthProvidersManageScope,
82 );87 );
83 expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe(88 expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe(
84 AuthOrchestrationOperateScope,89 AuthProvidersManageScope,
85 );90 );
86 expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe(91 expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe(
87 AuthOrchestrationReadScope,92 AuthOrchestrationReadScope,
...@@ -90,7 +95,7 @@ describe("RPC authorization scopes", () => {...@@ -90,7 +95,7 @@ describe("RPC authorization scopes", () => {
90 AuthOrchestrationOperateScope,95 AuthOrchestrationOperateScope,
91 );96 );
92 expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe(97 expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe(
93 AuthOrchestrationOperateScope,98 AuthProvidersManageScope,
94 );99 );
95 });100 });
96101
...@@ -160,9 +165,78 @@ describe("RPC scope middleware", () => {...@@ -160,9 +165,78 @@ describe("RPC scope middleware", () => {
160 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),165 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
161 ).toMatchObject({166 ).toMatchObject({
162 _tag: "EnvironmentAuthorizationError",167 _tag: "EnvironmentAuthorizationError",
163 requiredScope: AuthOrchestrationOperateScope,168 requiredScope: AuthEnvironmentMaintainScope,
164 });169 });
165 expect(handled).toEqual([]);170 expect(handled).toEqual([]);
166 }).pipe(Effect.scoped),171 }).pipe(Effect.scoped),
167 );172 );
168});173});
174
175describe("settings mutation authorization", () => {
176 const group = WsRpcGroup.omit(
177 ...[...WsRpcGroup.requests.keys()].filter(
178 (
179 tag,
180 ): tag is Exclude<keyof typeof RPC_REQUIRED_SCOPES, typeof WS_METHODS.serverUpdateSettings> =>
181 tag !== WS_METHODS.serverUpdateSettings,
182 ),
183 );
184 const providerInstanceMutation = {
185 operation: "remove" as const,
186 instanceId: ProviderInstanceId.make("codex_work"),
187 };
188
189 it.effect("allows provider-only mutations while denying mixed settings without their grant", () =>
190 Effect.gen(function* () {
191 let handled = 0;
192 const client = yield* RpcTest.makeClient(group).pipe(
193 Effect.provide(
194 Layer.mergeAll(
195 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
196 Effect.sync(() => {
197 handled++;
198 return DEFAULT_SERVER_SETTINGS;
199 }),
200 ),
201 RpcAuthorization.layer([AuthProvidersManageScope]),
202 ),
203 ),
204 );
205 yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation });
206 expect(handled).toBe(1);
207 expect(
208 yield* client[WS_METHODS.serverUpdateSettings]({
209 patch: { defaultRuntimeMode: "full-access" },
210 providerInstanceMutation,
211 }).pipe(Effect.flip),
212 ).toMatchObject({ requiredScope: AuthSettingsWriteScope });
213 expect(handled).toBe(1);
214 }).pipe(Effect.scoped),
215 );
216
217 it.effect("does not let a settings grant create or remove providers", () =>
218 Effect.gen(function* () {
219 let handled = false;
220 const client = yield* RpcTest.makeClient(group).pipe(
221 Effect.provide(
222 Layer.mergeAll(
223 group.toLayerHandler(WS_METHODS.serverUpdateSettings, () =>
224 Effect.sync(() => {
225 handled = true;
226 return DEFAULT_SERVER_SETTINGS;
227 }),
228 ),
229 RpcAuthorization.layer([AuthSettingsWriteScope]),
230 ),
231 ),
232 );
233 expect(
234 yield* client[WS_METHODS.serverUpdateSettings]({
235 patch: {},
236 providerInstanceMutation,
237 }).pipe(Effect.flip),
238 ).toMatchObject({ requiredScope: AuthProvidersManageScope });
239 expect(handled).toBe(false);
240 }).pipe(Effect.scoped),
241 );
242});
apps/server/src/auth/RpcAuthorization.ts+64-43
...@@ -1,6 +1,13 @@...@@ -1,6 +1,13 @@
1import {1import {
2 CLIENT_GUARDED_RPC_SCOPES,
2 type DeviceListInput,3 type DeviceListInput,
3 AuthAccessReadScope,4 AuthAccessReadScope,
5 ServerSettingsPatch,
6 ProviderInstanceMutation,
7 requiredScopesForServerSettingsPatch,
8 AuthSettingsWriteScope,
9 AuthProvidersManageScope,
10 AuthEnvironmentMaintainScope,
4 AuthOrchestrationOperateScope,11 AuthOrchestrationOperateScope,
5 AuthOrchestrationReadScope,12 AuthOrchestrationReadScope,
6 AuthRelayReadScope,13 AuthRelayReadScope,
...@@ -15,6 +22,7 @@ import {...@@ -15,6 +22,7 @@ import {
15 WsRpcGroup,22 WsRpcGroup,
16} from "@t3tools/contracts";23} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
18import * as Layer from "effect/Layer";26import * as Layer from "effect/Layer";
19import type * as RpcGroup from "effect/rpc/RpcGroup";27import type * as RpcGroup from "effect/rpc/RpcGroup";
2028
...@@ -26,6 +34,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];...@@ -26,6 +34,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
26 * runtime failure.34 * runtime failure.
27 */35 */
28export const RPC_REQUIRED_SCOPES = {36export const RPC_REQUIRED_SCOPES = {
37 ...CLIENT_GUARDED_RPC_SCOPES,
29 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,38 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
30 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,39 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
31 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,40 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
...@@ -41,61 +50,56 @@ export const RPC_REQUIRED_SCOPES = {...@@ -41,61 +50,56 @@ export const RPC_REQUIRED_SCOPES = {
41 [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,50 [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
42 [WS_METHODS.serverProbe]: AuthOrchestrationReadScope,51 [WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
43 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,52 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
44 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,53 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
45 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,54 [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
46 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,55 [WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
47 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,56 [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,
48 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,57 [WS_METHODS.providerAuthComplete]: AuthProvidersManageScope,
49 [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope,58 [WS_METHODS.chatGptReconnectProfile]: AuthProvidersManageScope,
50 [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope,59 [WS_METHODS.chatGptImportProfile]: AuthProvidersManageScope,
51 [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope,60 [WS_METHODS.chatGptHandoffSubscribe]: AuthProvidersManageScope,
52 [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope,61 [WS_METHODS.codexAuthCallbackSubscribe]: AuthProvidersManageScope,
53 [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,62 [WS_METHODS.providerAuthRespond]: AuthProvidersManageScope,
54 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,63 [WS_METHODS.providerAuthCancel]: AuthProvidersManageScope,
55 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,64 [WS_METHODS.providerAuthLogout]: AuthProvidersManageScope,
56 [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope,65 [WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope,
57 [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope,66 [WS_METHODS.providerInstallStart]: AuthProvidersManageScope,
58 [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope,67 [WS_METHODS.providerInstallCancel]: AuthProvidersManageScope,
59 [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope,68 [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope,
60 [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope,69 [WS_METHODS.providerInstallRemove]: AuthProvidersManageScope,
61 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,70 [WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope,
62 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,71 [WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope,
63 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,72 [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope,
64 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,73 [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope,
65 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,74 [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
66 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,75 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
67 [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,76 [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
68 [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,77 [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
69 [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthOrchestrationOperateScope,78 [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
70 [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthOrchestrationOperateScope,79 [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope,
71 [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthOrchestrationOperateScope,80 [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope,
72 [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope,81 [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope,
73 [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope,82 [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope,
74 [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope,83 [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope,
75 [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope,84 [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope,
76 [WS_METHODS.serverSetAcpRegistryProvider]: AuthOrchestrationOperateScope,85 [WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope,
77 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthOrchestrationOperateScope,86 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
78 [WS_METHODS.serverLogoutAcpRegistry]: AuthOrchestrationOperateScope,87 [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
79 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,88 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
80 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,89 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
81 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,90 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
82 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,91 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
83 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,92 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
84 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,93 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
85 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,94 [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope,
86 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,95 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
87 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,96 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
88 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,97 [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
89 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,98 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
90 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,99 [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,
91 [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,100 [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,
92 [WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope,101 [WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope,
93 [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope,102 [WS_METHODS.scheduledTasksSubscribe]: AuthOrchestrationReadScope,
94 [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope,
95 [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
96 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
97 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
98 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
99 [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,103 [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
100 // Delivery logs hold request bodies, so they need the same scope as the URL.104 // Delivery logs hold request bodies, so they need the same scope as the URL.
101 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,105 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
...@@ -195,8 +199,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -195,8 +199,8 @@ export const RPC_REQUIRED_SCOPES = {
195 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,199 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
196 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,200 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
197 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,201 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
198 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,202 [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,
199 [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,203 [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope,
200 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,204 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,
201 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,205 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
202 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,206 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
...@@ -227,13 +231,30 @@ export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>...@@ -227,13 +231,30 @@ export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
227 requiredScope,231 requiredScope,
228 });232 });
229233
234const SettingsUpdate = Schema.Struct({
235 patch: ServerSettingsPatch,
236 providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation),
237});
238
239const requiredScopesForSettingsUpdate = (payload: unknown) => {
240 const input = Schema.decodeUnknownSync(SettingsUpdate)(payload);
241 const scopes = requiredScopesForServerSettingsPatch(input.patch);
242 if (input.providerInstanceMutation === undefined) return scopes;
243 // An atomic provider mutation carries an empty patch unless it also changes settings.
244 return Object.values(input.patch).every((value) => value === undefined)
245 ? [AuthProvidersManageScope]
246 : [...new Set([...scopes, AuthProvidersManageScope])];
247};
248
230/** Authorizes every RPC on one connection against that connection's session scopes. */249/** Authorizes every RPC on one connection against that connection's session scopes. */
231export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>250export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>
232 Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => {251 Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
233 const requiredScope = requiredScopeForRpcMethod(rpc._tag);252 const requiredScopes =
234 return scopes.includes(requiredScope)253 rpc._tag === WS_METHODS.serverUpdateSettings
235 ? effect254 ? requiredScopesForSettingsUpdate(payload)
236 : Effect.fail(rpcAuthorizationError(requiredScope));255 : [requiredScopeForRpcMethod(rpc._tag)];
256 const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
257 return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope));
237 });258 });
238259
239/** Retrying can install or restart tools even though ordinary listing is readable. */260/** Retrying can install or restart tools even though ordinary listing is readable. */
apps/server/src/auth/SessionStore.test.ts+2-8
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentId } from "@t3tools/contracts";2import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
...@@ -346,13 +346,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -346,13 +346,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
346346
347 expect(verified.method).toBe("bearer-access-token");347 expect(verified.method).toBe("bearer-access-token");
348 expect(verified.subject).toBe("test-clock");348 expect(verified.subject).toBe("test-clock");
349 expect(verified.scopes).toEqual([349 expect(verified.scopes).toEqual(AuthStandardClientScopes);
350 "orchestration:read",
351 "orchestration:operate",
352 "terminal:operate",
353 "review:write",
354 "relay:read",
355 ]);
356 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),350 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
357 );351 );
358352
apps/server/src/auth/http.ts+6
...@@ -2,6 +2,9 @@ import {...@@ -2,6 +2,9 @@ import {
2 AuthAccessReadScope,2 AuthAccessReadScope,
3 AuthAccessWriteScope,3 AuthAccessWriteScope,
4 AuthStandardClientScopes,4 AuthStandardClientScopes,
5 AuthSettingsWriteScope,
6 AuthProvidersManageScope,
7 AuthEnvironmentMaintainScope,
5 AuthOrchestrationOperateScope,8 AuthOrchestrationOperateScope,
6 AuthOrchestrationReadScope,9 AuthOrchestrationReadScope,
7 AuthRelayReadScope,10 AuthRelayReadScope,
...@@ -366,6 +369,9 @@ export const layer = HttpApiBuilder.group(...@@ -366,6 +369,9 @@ export const layer = HttpApiBuilder.group(
366 allowedScopes: new Set<AuthEnvironmentScope>([369 allowedScopes: new Set<AuthEnvironmentScope>([
367 AuthOrchestrationReadScope,370 AuthOrchestrationReadScope,
368 AuthOrchestrationOperateScope,371 AuthOrchestrationOperateScope,
372 AuthSettingsWriteScope,
373 AuthProvidersManageScope,
374 AuthEnvironmentMaintainScope,
369 AuthTerminalOperateScope,375 AuthTerminalOperateScope,
370 AuthReviewWriteScope,376 AuthReviewWriteScope,
371 AuthAccessReadScope,377 AuthAccessReadScope,
apps/server/src/observability/RpcInstrumentation.test.ts+2-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import {2import {
3 AuthOrchestrationOperateScope,3 AuthEnvironmentMaintainScope,
4 AuthOrchestrationReadScope,4 AuthOrchestrationReadScope,
5 type AuthEnvironmentScope,5 type AuthEnvironmentScope,
6 ScheduledTaskError,6 ScheduledTaskError,
...@@ -319,7 +319,7 @@ describe("WS RPC instrumentation middleware", () => {...@@ -319,7 +319,7 @@ describe("WS RPC instrumentation middleware", () => {
319 message: Option.none(),319 message: Option.none(),
320 }).pipe(Effect.withSpan("signalProcess.child")),320 }).pipe(Effect.withSpan("signalProcess.child")),
321 ),321 ),
322 connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]),322 connectionMiddleware([AuthOrchestrationReadScope, AuthEnvironmentMaintainScope]),
323 ),323 ),
324 ),324 ),
325 );325 );
apps/server/src/provider/ProviderRegistry.test.ts+93-1
...@@ -2,7 +2,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";...@@ -2,7 +2,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "./CodexInstallation.ts";2import * as CodexInstallation from "./CodexInstallation.ts";
3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
4import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, it, assert } from "@effect/vitest";5import { describe, it, assert, expect } from "@effect/vitest";
6import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
...@@ -2077,6 +2077,98 @@ it.layer(...@@ -2077,6 +2077,98 @@ it.layer(
2077 ]);2077 ]);
2078 });2078 });
20792079
2080 it.effect("shares one in-flight pass between concurrent untargeted refreshes", () =>
2081 Effect.gen(function* () {
2082 const driver = ProviderDriverKind.make("codex");
2083 const instanceId = ProviderInstanceId.make("codex");
2084 const provider = {
2085 instanceId,
2086 driver,
2087 status: "ready",
2088 enabled: true,
2089 installed: true,
2090 auth: { status: "authenticated" },
2091 checkedAt: "2026-06-10T00:00:00.000Z",
2092 version: "1.0.0",
2093 models: [],
2094 slashCommands: [],
2095 skills: [],
2096 } as const satisfies ServerProvider;
2097 const refreshCalls = yield* Ref.make(0);
2098 const probeStarted = yield* Deferred.make<void>();
2099 const releaseProbe = yield* Deferred.make<void>();
2100 const instances = [
2101 {
2102 instanceId,
2103 driverKind: driver,
2104 continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" },
2105 displayName: undefined,
2106 enabled: true,
2107 snapshot: {
2108 resolveMaintenance: () =>
2109 Effect.succeed(
2110 makeManualOnlyProviderMaintenanceCapabilities({
2111 provider: driver,
2112 packageName: null,
2113 }),
2114 ),
2115 getSnapshot: Effect.succeed(provider),
2116 refresh: Effect.gen(function* () {
2117 yield* Ref.update(refreshCalls, (count) => count + 1);
2118 yield* Deferred.succeed(probeStarted, undefined);
2119 yield* Deferred.await(releaseProbe);
2120 return provider;
2121 }),
2122 streamChanges: Stream.empty,
2123 applyUsageLimits: () => Effect.void,
2124 },
2125 orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
2126 textGeneration: {} as ProviderInstance["textGeneration"],
2127 },
2128 ] satisfies ReadonlyArray<ProviderInstance>;
2129 const instanceRegistryLayer = Layer.succeed(
2130 ProviderInstanceRegistry.ProviderInstanceRegistry,
2131 {
2132 getInstance: (requestedId) =>
2133 Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)),
2134 listInstances: Effect.succeed(instances),
2135 listUnavailable: Effect.succeed([]),
2136 streamChanges: Stream.empty,
2137 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe),
2138 },
2139 );
2140 const scope = yield* Scope.make();
2141 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2142 const runtimeServices = yield* Layer.build(
2143 ProviderRegistry.layer.pipe(
2144 Layer.provideMerge(instanceRegistryLayer),
2145 Layer.provideMerge(
2146 ServerConfig.layerTest(process.cwd(), {
2147 prefix: "t3-provider-registry-shared-refresh-",
2148 }),
2149 ),
2150 Layer.provideMerge(NodeServices.layer),
2151 ),
2152 ).pipe(Scope.provide(scope));
2153
2154 yield* Effect.gen(function* () {
2155 const registry = yield* ProviderRegistry.ProviderRegistry;
2156 const first = yield* registry.refresh().pipe(Effect.forkChild);
2157 yield* Deferred.await(probeStarted);
2158 const second = yield* registry.refresh().pipe(Effect.forkChild);
2159 yield* Effect.yieldNow;
2160 assert.strictEqual(yield* Ref.get(refreshCalls), 1);
2161 yield* Deferred.succeed(releaseProbe, undefined);
2162 expect(yield* Fiber.join(first)).toEqual([expect.objectContaining(provider)]);
2163 expect(yield* Fiber.join(second)).toEqual([expect.objectContaining(provider)]);
2164 assert.strictEqual(yield* Ref.get(refreshCalls), 1);
2165
2166 yield* registry.refresh();
2167 assert.strictEqual(yield* Ref.get(refreshCalls), 2);
2168 }).pipe(Effect.provide(runtimeServices));
2169 }),
2170 );
2171
2080 it.effect("persists the merged snapshot when a live update has empty models", () =>2172 it.effect("persists the merged snapshot when a live update has empty models", () =>
2081 Effect.gen(function* () {2173 Effect.gen(function* () {
2082 const cursorDriver = ProviderDriverKind.make("cursor");2174 const cursorDriver = ProviderDriverKind.make("cursor");
apps/server/src/provider/ProviderRegistry.ts+45-5
...@@ -36,10 +36,12 @@ import {...@@ -36,10 +36,12 @@ import {
36} from "@t3tools/contracts";36} from "@t3tools/contracts";
37import * as Cause from "effect/Cause";37import * as Cause from "effect/Cause";
38import * as Context from "effect/Context";38import * as Context from "effect/Context";
39import * as Deferred from "effect/Deferred";
39import * as Effect from "effect/Effect";40import * as Effect from "effect/Effect";
40import * as Equal from "effect/Equal";41import * as Equal from "effect/Equal";
41import * as FileSystem from "effect/FileSystem";42import * as FileSystem from "effect/FileSystem";
42import * as Layer from "effect/Layer";43import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
43import * as Path from "effect/Path";45import * as Path from "effect/Path";
44import * as PubSub from "effect/PubSub";46import * as PubSub from "effect/PubSub";
45import * as Ref from "effect/Ref";47import * as Ref from "effect/Ref";
...@@ -701,12 +703,50 @@ export const layer = Layer.effect(...@@ -701,12 +703,50 @@ export const layer = Layer.effect(
701 );703 );
702 });704 });
703705
706 type RefreshAllCompletion = Deferred.Deferred<ReadonlyArray<ServerProvider>>;
707 const refreshAllInFlightRef = yield* Ref.make<Option.Option<RefreshAllCompletion>>(
708 Option.none(),
709 );
710
711 // Untargeted refreshes probe every live source, so any read-scoped client
712 // can request them. Concurrent callers share one in-flight pass instead of
713 // each starting their own set of provider processes.
704 const refreshAll = Effect.fn("refreshAll")(function* () {714 const refreshAll = Effect.fn("refreshAll")(function* () {
705 const sources = yield* getLiveSources;715 const claimed = yield* Ref.modify(
706 return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {716 refreshAllInFlightRef,
707 concurrency: "unbounded",717 (
708 discard: true,718 inFlight,
709 }).pipe(Effect.andThen(Ref.get(providersRef)));719 ): readonly [
720 { readonly owner: boolean; readonly completion: RefreshAllCompletion },
721 Option.Option<RefreshAllCompletion>,
722 ] => {
723 if (Option.isSome(inFlight)) {
724 return [{ owner: false, completion: inFlight.value }, inFlight];
725 }
726 const completion = Deferred.makeUnsafe<ReadonlyArray<ServerProvider>>();
727 return [{ owner: true, completion }, Option.some(completion)];
728 },
729 );
730 if (claimed.owner) {
731 // Detached so an interrupted first caller cannot cancel the pass that
732 // other callers are already awaiting.
733 yield* getLiveSources.pipe(
734 Effect.flatMap((sources) =>
735 Effect.forEach(sources, (source) => refreshOneSource(source), {
736 concurrency: "unbounded",
737 discard: true,
738 }),
739 ),
740 Effect.andThen(Ref.get(providersRef)),
741 Effect.onExit((exit) =>
742 Ref.set(refreshAllInFlightRef, Option.none()).pipe(
743 Effect.andThen(Deferred.done(claimed.completion, exit)),
744 ),
745 ),
746 Effect.forkDetach,
747 );
748 }
749 return yield* Deferred.await(claimed.completion);
710 });750 });
711751
712 const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {752 const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {
apps/server/src/serverSettings.test.ts+85
...@@ -686,6 +686,91 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -686,6 +686,91 @@ it.layer(NodeServices.layer)("server settings", (it) => {
686 }).pipe(Effect.provide(layerServerSettings())),686 }).pipe(Effect.provide(layerServerSettings())),
687 );687 );
688688
689 // Only driver-keyed instances are fallback candidates; a custom instance id
690 // is not one, so the selection stays put until the user changes it.
691 it.effect.each(["codex"])(
692 "falls back to enabled instance %s after disabling the selection",
693 (fallbackId) =>
694 Effect.scoped(
695 Effect.gen(function* () {
696 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
697 const serverConfig = yield* ServerConfig.ServerConfig;
698 const fileSystem = yield* FileSystem.FileSystem;
699 const writerId = ProviderInstanceId.make("writer");
700 const fallbackInstanceId = ProviderInstanceId.make(fallbackId);
701 const selection = { instanceId: writerId, model: "claude-sonnet-4-6" };
702 const providerInstances = {
703 [fallbackInstanceId]: {
704 driver: ProviderDriverKind.make("codex"),
705 enabled: true,
706 config: {},
707 },
708 [writerId]: {
709 driver: ProviderDriverKind.make("claudeAgent"),
710 enabled: true,
711 config: {},
712 },
713 };
714
715 yield* serverSettings.updateSettings({
716 providers: Object.fromEntries(
717 Object.keys(DEFAULT_SERVER_SETTINGS.providers).map(
718 (provider) => [provider, { enabled: false }] as const,
719 ),
720 ),
721 providerInstances,
722 textGenerationModelSelection: selection,
723 });
724 const changes = yield* serverSettings.subscribeChanges;
725
726 const next = yield* serverSettings.updateSettings({
727 providerInstances: {
728 ...providerInstances,
729 [writerId]: { ...providerInstances[writerId]!, enabled: false },
730 },
731 });
732 const fallbackSelection = {
733 instanceId: fallbackInstanceId,
734 model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model,
735 };
736 assert.deepEqual(next.textGenerationModelSelection, fallbackSelection);
737 assert.deepEqual(
738 (yield* serverSettings.getSettings).textGenerationModelSelection,
739 fallbackSelection,
740 );
741 const change = Option.getOrUndefined(yield* Stream.runHead(changes));
742 assert.deepEqual(change?.textGenerationModelSelection, fallbackSelection);
743
744 const persisted = yield* fileSystem
745 .readFileString(serverConfig.settingsPath)
746 .pipe(
747 Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))),
748 );
749 assert.deepEqual(persisted.textGenerationModelSelection, selection);
750
751 const restored = yield* serverSettings.updateSettings({ providerInstances });
752 assert.deepEqual(restored.textGenerationModelSelection, selection);
753 }),
754 ).pipe(Effect.provide(layerServerSettings())),
755 );
756
757 it.effect("skips explicitly disabled instances when choosing a legacy fallback", () =>
758 Effect.gen(function* () {
759 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
760 const next = yield* serverSettings.updateSettings({
761 providerInstances: {
762 [ProviderInstanceId.make("codex")]: {
763 driver: ProviderDriverKind.make("codex"),
764 enabled: false,
765 config: {},
766 },
767 },
768 });
769
770 assert.equal(next.textGenerationModelSelection.instanceId, "claudeAgent");
771 }).pipe(Effect.provide(layerServerSettings())),
772 );
773
689 it.effect("preserves enabled text generation selections for non-built-in drivers", () =>774 it.effect("preserves enabled text generation selections for non-built-in drivers", () =>
690 Effect.gen(function* () {775 Effect.gen(function* () {
691 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;776 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/web/src/cloud/primaryCloudLinkState.ts+20-4
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts";2import {
3 AuthRelayReadScope,
4 EnvironmentId,
5 type EnvironmentCloudLinkStateResult,
6} from "@t3tools/contracts";
3import * as Cause from "effect/Cause";7import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";8import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
...@@ -12,6 +16,7 @@ import { useCallback, useMemo } from "react";...@@ -12,6 +16,7 @@ import { useCallback, useMemo } from "react";
12import { usePrimaryEnvironment } from "../state/environments";16import { usePrimaryEnvironment } from "../state/environments";
13import { runtime } from "../lib/runtime";17import { runtime } from "../lib/runtime";
14import { appAtomRegistry } from "../rpc/atomRegistry";18import { appAtomRegistry } from "../rpc/atomRegistry";
19import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
15import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";20import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";
16import { hasCloudPublicConfig } from "./publicConfig";21import { hasCloudPublicConfig } from "./publicConfig";
1722
...@@ -44,13 +49,24 @@ function targetKey(target: CloudLinkTarget): string {...@@ -44,13 +49,24 @@ function targetKey(target: CloudLinkTarget): string {
44}49}
4550
46function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void {51function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void {
47 if (target) {52 if (
53 target &&
54 readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope)
55 ) {
48 appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target)));56 appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target)));
49 }57 }
50}58}
5159
60export function readCachedPrimaryCloudLinkState(target: CloudLinkTarget) {
61 if (!readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope))
62 return null;
63 const result = appAtomRegistry.get(primaryCloudLinkStateAtom(targetKey(target)));
64 return result._tag === "Success" ? result.value : null;
65}
66
52export function usePrimaryCloudLinkState() {67export function usePrimaryCloudLinkState() {
53 const primary = usePrimaryEnvironment();68 const primary = usePrimaryEnvironment();
69 const canReadRelay = useEnvironmentScope(primary?.environmentId ?? null, AuthRelayReadScope);
54 const target = useMemo(70 const target = useMemo(
55 () =>71 () =>
56 primary?.entry.target._tag === "PrimaryConnectionTarget"72 primary?.entry.target._tag === "PrimaryConnectionTarget"
...@@ -65,7 +81,7 @@ export function usePrimaryCloudLinkState() {...@@ -65,7 +81,7 @@ export function usePrimaryCloudLinkState() {
65 );81 );
66 // Builds without T3 Connect have no link to read; skip the request.82 // Builds without T3 Connect have no link to read; skip the request.
67 const atom =83 const atom =
68 target && hasCloudPublicConfig()84 target && hasCloudPublicConfig() && canReadRelay
69 ? primaryCloudLinkStateAtom(targetKey(target))85 ? primaryCloudLinkStateAtom(targetKey(target))
70 : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;86 : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;
71 const result = useAtomValue(atom);87 const result = useAtomValue(atom);
...@@ -79,7 +95,7 @@ export function usePrimaryCloudLinkState() {...@@ -79,7 +95,7 @@ export function usePrimaryCloudLinkState() {
79 }95 }
8096
81 return {97 return {
82 data: Option.getOrNull(AsyncResult.value(result)),98 data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)),
83 error,99 error,
84 isPending: result.waiting,100 isPending: result.waiting,
85 refresh,101 refresh,
apps/web/src/cloud/useCloudLinkController.test.tsx created+230
...@@ -0,0 +1,230 @@
1import {
2 AuthRelayReadScope,
3 AuthRelayWriteScope,
4 EnvironmentId,
5 type EnvironmentCloudLinkStateResult,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { act, useLayoutEffect } from "react";
9import { create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const testState = vi.hoisted(() => ({
13 scopes: new Set<string>(),
14 snapshot: null as EnvironmentCloudLinkStateResult | null,
15 renderedSnapshot: null as EnvironmentCloudLinkStateResult | null,
16 getToken: vi.fn<() => Promise<string | null>>(),
17 link: vi.fn(),
18 unlink: vi.fn(),
19 preferences: vi.fn(),
20 refreshDiscovery: vi.fn(),
21 refreshLink: vi.fn(),
22 toast: vi.fn(),
23}));
24
25vi.mock("@clerk/react", () => ({
26 useAuth: () => ({ getToken: testState.getToken, isSignedIn: true }),
27}));
28vi.mock("../components/ui/toast", () => ({ toastManager: { add: testState.toast } }));
29vi.mock("../state/relay", () => ({
30 relayEnvironmentDiscovery: { refresh: testState.refreshDiscovery },
31}));
32vi.mock("../state/session", () => ({
33 readEnvironmentScope: (_environmentId: string, scope: string) => testState.scopes.has(scope),
34}));
35vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
36vi.mock("./linkEnvironmentAtoms", () => ({
37 linkPrimaryEnvironment: testState.link,
38 unlinkPrimaryEnvironment: testState.unlink,
39 updatePrimaryEnvironmentPreferences: testState.preferences,
40}));
41vi.mock("./primaryCloudLinkState", () => ({
42 readCachedPrimaryCloudLinkState: () => testState.snapshot,
43 usePrimaryCloudLinkState: () => ({
44 target,
45 data: testState.renderedSnapshot,
46 error: null,
47 isPending: false,
48 refresh: testState.refreshLink,
49 }),
50}));
51vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) }));
52
53import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController";
54
55const target = {
56 environmentId: EnvironmentId.make("primary"),
57 label: "Primary",
58 httpBaseUrl: "http://localhost:3773",
59 wsBaseUrl: "ws://localhost:3773/ws",
60};
61const linkedState: EnvironmentCloudLinkStateResult = {
62 linked: true,
63 cloudUserId: "account-1",
64 relayUrl: "https://relay.example.com",
65 relayIssuer: "https://relay.example.com",
66 managedTunnelActive: true,
67 publishAgentActivity: false,
68};
69
70let renderer: ReactTestRenderer | null = null;
71let controller: ReturnType<typeof useCloudLinkController> | null = null;
72
73function ControllerProbe() {
74 const value = useCloudLinkController();
75 useLayoutEffect(() => {
76 controller = value;
77 });
78 return null;
79}
80
81async function mountController() {
82 await act(() => {
83 renderer = create(<ControllerProbe />);
84 });
85}
86
87async function reconcile(desired: CloudLinkDesiredState) {
88 let succeeded = false;
89 await act(async () => {
90 if (controller === null) throw new Error("Controller is not mounted.");
91 succeeded = await controller.reconcileCloudState(desired);
92 });
93 return succeeded;
94}
95
96function expectNoMutations() {
97 expect(testState.link).not.toHaveBeenCalled();
98 expect(testState.unlink).not.toHaveBeenCalled();
99 expect(testState.preferences).not.toHaveBeenCalled();
100}
101
102beforeEach(() => {
103 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
104 vi.spyOn(console, "error").mockImplementation(() => undefined);
105 testState.scopes = new Set([AuthRelayReadScope, AuthRelayWriteScope]);
106 testState.snapshot = linkedState;
107 testState.renderedSnapshot = linkedState;
108 testState.getToken.mockReset().mockResolvedValue("clerk-token");
109 for (const command of [
110 testState.link,
111 testState.unlink,
112 testState.preferences,
113 testState.refreshDiscovery,
114 ]) {
115 command.mockReset().mockResolvedValue(AsyncResult.success({}));
116 }
117 testState.refreshLink.mockReset();
118 testState.toast.mockReset();
119});
120
121afterEach(async () => {
122 await act(() => renderer?.unmount());
123 renderer = null;
124 controller = null;
125 vi.restoreAllMocks();
126 vi.unstubAllGlobals();
127});
128
129describe("useCloudLinkController", () => {
130 it.each([AuthRelayReadScope, AuthRelayWriteScope])(
131 "does not mutate without %s, even with a cached link snapshot",
132 async (scope) => {
133 await mountController();
134 testState.scopes.delete(scope);
135
136 expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false);
137 expect(testState.getToken).not.toHaveBeenCalled();
138 expectNoMutations();
139 },
140 );
141
142 it("does not interpret unavailable link state as an unlinked environment", async () => {
143 testState.snapshot = null;
144 await mountController();
145
146 expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false);
147 expect(testState.getToken).not.toHaveBeenCalled();
148 expectNoMutations();
149 });
150
151 it.each([AuthRelayReadScope, AuthRelayWriteScope])(
152 "rechecks %s after requesting the cloud token",
153 async (scope) => {
154 await mountController();
155 testState.getToken.mockImplementationOnce(async () => {
156 testState.scopes.delete(scope);
157 return "clerk-token";
158 });
159
160 expect(await reconcile({ managedTunnel: false, publish: false })).toBe(false);
161 expectNoMutations();
162 },
163 );
164
165 it("stops if the link-state read fails while requesting the cloud token", async () => {
166 await mountController();
167 testState.getToken.mockImplementationOnce(async () => {
168 testState.snapshot = null;
169 return "clerk-token";
170 });
171
172 expect(await reconcile({ managedTunnel: true, publish: true })).toBe(false);
173 expectNoMutations();
174 });
175
176 it("uses the latest linked mode to change publishing without replacing the tunnel", async () => {
177 testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false };
178 testState.renderedSnapshot = testState.snapshot;
179 await mountController();
180 testState.getToken.mockImplementationOnce(async () => {
181 testState.snapshot = linkedState;
182 return "clerk-token";
183 });
184
185 expect(await reconcile({ managedTunnel: true, publish: true })).toBe(true);
186 expect(testState.link).not.toHaveBeenCalled();
187 expect(testState.unlink).not.toHaveBeenCalled();
188 expect(testState.preferences).toHaveBeenCalledExactlyOnceWith({
189 target,
190 publishAgentActivity: true,
191 });
192 });
193
194 it("stops the preference write if management permission is lost after linking", async () => {
195 await mountController();
196 testState.link.mockImplementationOnce(async () => {
197 testState.scopes.delete(AuthRelayWriteScope);
198 return AsyncResult.success({});
199 });
200
201 expect(await reconcile({ managedTunnel: false, publish: true })).toBe(false);
202 expect(testState.link).toHaveBeenCalledOnce();
203 expect(testState.preferences).not.toHaveBeenCalled();
204 expect(testState.refreshLink).toHaveBeenCalledOnce();
205 });
206
207 it("still lets an authorized user unlink when the cloud token is unavailable", async () => {
208 await mountController();
209 testState.getToken.mockRejectedValueOnce(new Error("Cloud sign-in unavailable"));
210
211 expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true);
212 expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
213 expect(testState.link).not.toHaveBeenCalled();
214 expect(testState.preferences).not.toHaveBeenCalled();
215 });
216
217 it("unlinks if the link-state read fails while requesting the cloud token", async () => {
218 await mountController();
219 testState.getToken.mockImplementationOnce(async () => {
220 testState.snapshot = null;
221 throw new Error("Cloud sign-in unavailable");
222 });
223
224 expect(await reconcile({ managedTunnel: false, publish: false })).toBe(true);
225 expect(testState.unlink).toHaveBeenCalledExactlyOnceWith({ target, clerkToken: null });
226 expect(testState.link).not.toHaveBeenCalled();
227 expect(testState.preferences).not.toHaveBeenCalled();
228 expect(testState.refreshLink).toHaveBeenCalledOnce();
229 });
230});
apps/web/src/cloud/useCloudLinkController.ts+35-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { findErrorTraceId } from "@t3tools/client-runtime/errors";2import { findErrorTraceId } from "@t3tools/client-runtime/errors";
3import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts";
3import {4import {
4 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
5 settlePromise,6 settlePromise,
...@@ -9,13 +10,14 @@ import { useState } from "react";...@@ -9,13 +10,14 @@ import { useState } from "react";
910
10import { toastManager } from "../components/ui/toast";11import { toastManager } from "../components/ui/toast";
11import { relayEnvironmentDiscovery } from "../state/relay";12import { relayEnvironmentDiscovery } from "../state/relay";
13import { readEnvironmentScope } from "../state/session";
12import { useAtomCommand } from "../state/use-atom-command";14import { useAtomCommand } from "../state/use-atom-command";
13import {15import {
14 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,16 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
15 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,17 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
16 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,18 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
17} from "./linkEnvironmentAtoms";19} from "./linkEnvironmentAtoms";
18import { usePrimaryCloudLinkState } from "./primaryCloudLinkState";20import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState";
19import { resolveRelayClerkTokenOptions } from "./publicConfig";21import { resolveRelayClerkTokenOptions } from "./publicConfig";
2022
21export interface CloudLinkDesiredState {23export interface CloudLinkDesiredState {
...@@ -87,8 +89,31 @@ export function useCloudLinkController() {...@@ -87,8 +89,31 @@ export function useCloudLinkController() {
87 reportUpdateFailure(new Error("Local environment is not ready yet."));89 reportUpdateFailure(new Error("Local environment is not ready yet."));
88 return false;90 return false;
89 }91 }
90 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));92 const environmentId = EnvironmentId.make(target.environmentId);
93 const canManageLink = () => {
94 if (
95 !readEnvironmentScope(environmentId, AuthRelayReadScope) ||
96 !readEnvironmentScope(environmentId, AuthRelayWriteScope)
97 ) {
98 reportUpdateFailure(
99 new Error("This connection needs permission to view and manage T3 Connect settings."),
100 );
101 return false;
102 }
103 return true;
104 };
105 const readLinkState = () => {
106 const state = readCachedPrimaryCloudLinkState(target);
107 if (state === null) {
108 reportUpdateFailure(new Error("Wait until the current T3 Connect settings can be read."));
109 }
110 return state;
111 };
112 if (!canManageLink()) return false;
91 const wantsLink = desired.managedTunnel || desired.publish;113 const wantsLink = desired.managedTunnel || desired.publish;
114 if (wantsLink && readLinkState() === null) return false;
115 const tokenResult = await settlePromise(() => getToken(resolveRelayClerkTokenOptions()));
116 if (!canManageLink()) return false;
92117
93 // A failure after this point may follow a partially applied mutation (e.g.118 // A failure after this point may follow a partially applied mutation (e.g.
94 // the link succeeded but the preference update did not), so every exit —119 // the link succeeded but the preference update did not), so every exit —
...@@ -109,6 +134,8 @@ export function useCloudLinkController() {...@@ -109,6 +134,8 @@ export function useCloudLinkController() {
109 return false;134 return false;
110 }135 }
111 } else {136 } else {
137 const currentLinkState = readLinkState();
138 if (currentLinkState === null) return false;
112 if (tokenResult._tag === "Failure") {139 if (tokenResult._tag === "Failure") {
113 reportUpdateFailure(squashAtomCommandFailure(tokenResult));140 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
114 return false;141 return false;
...@@ -118,7 +145,8 @@ export function useCloudLinkController() {...@@ -118,7 +145,8 @@ export function useCloudLinkController() {
118 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));145 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
119 return false;146 return false;
120 }147 }
121 if (!linked || managedTunnelActive !== desired.managedTunnel) {148 const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked;
149 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {
122 const linkResult = await linkPrimaryEnvironment({150 const linkResult = await linkPrimaryEnvironment({
123 target,151 target,
124 clerkToken,152 clerkToken,
...@@ -132,6 +160,10 @@ export function useCloudLinkController() {...@@ -132,6 +160,10 @@ export function useCloudLinkController() {
132 return false;160 return false;
133 }161 }
134 }162 }
163 if (!canManageLink() || readLinkState() === null) {
164 primaryCloudLinkState.refresh();
165 return false;
166 }
135 const prefResult = await updatePrimaryEnvironmentPreferences({167 const prefResult = await updatePrimaryEnvironmentPreferences({
136 target,168 target,
137 publishAgentActivity: desired.publish,169 publishAgentActivity: desired.publish,
apps/web/src/components/ChatMarkdown.permissions.test.tsx created+299
...@@ -0,0 +1,299 @@
1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ProjectId,
5 ThreadId,
6 type AuthEnvironmentScope,
7 type EditorId,
8 type ThreadLinkedPullRequest,
9} from "@t3tools/contracts";
10import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
11import { AsyncResult } from "effect/reactivity";
12import { cloneElement, type ReactElement, type ReactNode } from "react";
13import { act, create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
15
16const state = vi.hoisted(() => ({
17 allowed: true,
18 listeners: new Set<() => void>(),
19 openEditor: vi.fn(),
20 updateMetadata: vi.fn(),
21 search: vi.fn(),
22 choose: vi.fn(),
23 openFile: vi.fn(),
24 openExternal: vi.fn(),
25 copy: vi.fn(),
26 toast: vi.fn(),
27 linkedPullRequest: null as ThreadLinkedPullRequest | null,
28}));
29
30vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig }));
31vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
32vi.mock("../hooks/useSettings", () => ({
33 getClientSettings: () => DEFAULT_CLIENT_SETTINGS,
34 useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
35 select ? select(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS,
36}));
37vi.mock("./ui/tooltip", () => ({
38 Tooltip: ({ children }: { children: ReactNode }) => children,
39 TooltipTrigger: ({ render, children }: { render: ReactElement; children?: ReactNode }) =>
40 children === undefined ? render : cloneElement(render, undefined, children),
41 TooltipPopup: () => null,
42}));
43vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null }));
44vi.mock("./ui/toast", () => ({
45 toastManager: { add: state.toast },
46 stackedThreadToast: (value: unknown) => value,
47}));
48vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search }));
49vi.mock("../state/use-atom-command", () => ({
50 useAtomCommand: (command: string) =>
51 command === "updateMetadata" ? state.updateMetadata : state.openEditor,
52}));
53vi.mock("../state/session", async () => {
54 const { useSyncExternalStore } = await import("react");
55 const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
56 id !== null &&
57 (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed);
58 return {
59 // Asset atoms read these at module scope; the markdown tests only exercise
60 // the task grant.
61 environmentSession: {
62 sessionStateAtom: () => ({}),
63 preparedConnectionValueAtom: () => ({}),
64 },
65 readEnvironmentScope,
66 useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
67 useSyncExternalStore(
68 (listener) => {
69 state.listeners.add(listener);
70 return () => state.listeners.delete(listener);
71 },
72 () => readEnvironmentScope(id, scope),
73 ),
74 usePreparedConnection: () => ({ _tag: "None" }),
75 };
76});
77vi.mock("../state/server", () => ({
78 serverEnvironment: { configValueAtom: () => "config" },
79}));
80vi.mock("../state/threads", () => ({
81 threadEnvironment: { updateMetadata: "updateMetadata" },
82}));
83vi.mock("../state/entities", () => ({
84 readEnvironmentSupportsServerBrowser: () => false,
85 useServerConfigs: () => new Map([[threadRef.environmentId, serverConfig]]),
86 readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }),
87 useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }],
88}));
89vi.mock("../rightPanelStore", () => ({
90 useRightPanelStore: { getState: () => ({ openFile: state.openFile }) },
91}));
92vi.mock("../remoteOpen", () => ({
93 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
94}));
95vi.mock("../hooks/useLocalStorage", () => ({
96 useLocalStorage: () => ["vscode", vi.fn()],
97 getLocalStorageItem: () => "vscode",
98 setLocalStorageItem: vi.fn(),
99}));
100vi.mock("../hooks/useCopyToClipboard", () => ({
101 writeTextToClipboard: state.copy,
102 useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }),
103}));
104vi.mock("../localApi", () => ({
105 readLocalApi: () => ({
106 contextMenu: { show: state.choose },
107 shell: { openExternal: state.openExternal },
108 }),
109}));
110vi.mock("~/lib/openPullRequestLink", () => ({
111 resolvePullRequestPreviewTarget: () => null,
112 findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0],
113 matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) =>
114 candidate.url === href,
115 parseChangeRequestUrl: (href: string) =>
116 href === linkedPullRequest.url
117 ? {
118 host: "github.com",
119 repository: linkedPullRequest.repository,
120 number: linkedPullRequest.number,
121 }
122 : null,
123 useOpenChangeRequestLink: () => vi.fn(),
124}));
125
126import ChatMarkdown from "./ChatMarkdown";
127
128const threadRef = {
129 environmentId: EnvironmentId.make("selected"),
130 threadId: ThreadId.make("thread"),
131};
132const linkedPullRequest: ThreadLinkedPullRequest = {
133 projectId: ProjectId.make("project"),
134 repository: "example/repo",
135 number: 42,
136 url: "https://github.com/example/repo/pull/42",
137};
138const serverConfig = {
139 availableEditors: ["vscode", "file-manager"] as readonly EditorId[],
140 shellRevealInFileManager: true,
141 shellRevealInFileManagerKind: "xdg-open",
142 environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } },
143};
144let renderer: ReactTestRenderer | undefined;
145
146beforeEach(() => {
147 state.allowed = true;
148 state.listeners.clear();
149 state.linkedPullRequest = null;
150 state.openEditor.mockReset().mockResolvedValue(AsyncResult.success(undefined));
151 state.updateMetadata.mockReset().mockResolvedValue(AsyncResult.success(undefined));
152 state.search.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] }));
153 state.choose.mockReset().mockResolvedValue(null);
154 state.openFile.mockReset();
155 state.openExternal.mockReset().mockResolvedValue(undefined);
156 state.copy.mockReset().mockResolvedValue(undefined);
157 state.toast.mockReset();
158 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
159 vi.stubGlobal("window", {});
160 vi.stubGlobal("navigator", { clipboard: { writeText: state.copy } });
161});
162
163afterEach(async () => {
164 await act(async () => renderer?.unmount());
165 renderer = undefined;
166 vi.unstubAllGlobals();
167});
168
169async function renderMarkdown(text: string) {
170 await act(async () => {
171 renderer = create(<ChatMarkdown cwd="/work" threadRef={threadRef} text={text} />);
172 });
173}
174
175function menuEvent() {
176 return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 };
177}
178
179function anchor() {
180 return renderer!.root.findByType("a");
181}
182
183async function openContextMenu() {
184 await act(async () => anchor().props.onContextMenu(menuEvent()));
185}
186
187function offeredActions() {
188 return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id);
189}
190
191it("removes host actions after revocation while keeping file preview and copying", async () => {
192 await renderMarkdown("[Readme](docs/readme.md)");
193 await openContextMenu();
194 expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
195 await act(async () => {
196 state.allowed = false;
197 for (const listener of state.listeners) listener();
198 });
199 state.choose.mockResolvedValue("copy-full");
200 await openContextMenu();
201 expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
202 expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md");
203 await act(async () => anchor().props.onClick(menuEvent()));
204 expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined);
205 expect(state.openEditor).not.toHaveBeenCalled();
206});
207
208it("does not launch an editor after revocation during a native context menu", async () => {
209 let choose: (action: string) => void = () => {
210 throw new Error("Menu not opened");
211 };
212 state.choose.mockImplementationOnce(
213 () =>
214 new Promise<string>((resolve) => {
215 choose = resolve;
216 }),
217 );
218 await renderMarkdown("[Readme](docs/readme.md)");
219 await openContextMenu();
220 expect(offeredActions()).toContain("open");
221 await act(async () => {
222 state.allowed = false;
223 choose("open");
224 });
225 expect(state.openEditor).not.toHaveBeenCalled();
226});
227
228it("does not reveal a file after revocation during its workspace lookup", async () => {
229 let finishLookup: () => void = () => {
230 throw new Error("Lookup not started");
231 };
232 state.search.mockImplementationOnce(
233 () =>
234 new Promise((resolve) => {
235 finishLookup = () => resolve(AsyncResult.success({ entries: [] }));
236 }),
237 );
238 state.choose.mockResolvedValue("reveal");
239 await renderMarkdown("[Readme](readme.md)");
240 await openContextMenu();
241 expect(state.search).toHaveBeenCalledOnce();
242 await act(async () => {
243 state.allowed = false;
244 finishLookup();
245 });
246 expect(state.openEditor).not.toHaveBeenCalled();
247});
248
249it.each([false, true])(
250 "keeps PR links usable without offering metadata edits (linked=%s)",
251 async (linked) => {
252 state.allowed = false;
253 state.linkedPullRequest = linked ? linkedPullRequest : null;
254 state.choose.mockResolvedValue("open-external");
255 await renderMarkdown(`[Review](${linkedPullRequest.url})`);
256 await openContextMenu();
257 expect(offeredActions()).toEqual(["open-external", "copy-link"]);
258 expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url);
259 expect(state.updateMetadata).not.toHaveBeenCalled();
260 },
261);
262
263it.each(["link-to-thread", "unlink-from-thread"])(
264 "rechecks %s after a native context menu returns",
265 async (action) => {
266 state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null;
267 let choose: (action: string) => void = () => {
268 throw new Error("Menu not opened");
269 };
270 state.choose.mockImplementationOnce(
271 () =>
272 new Promise<string>((resolve) => {
273 choose = resolve;
274 }),
275 );
276 await renderMarkdown(`[Review](${linkedPullRequest.url})`);
277 await openContextMenu();
278 expect(offeredActions()).toContain(action);
279 await act(async () => {
280 state.allowed = false;
281 choose(action);
282 });
283 expect(state.updateMetadata).not.toHaveBeenCalled();
284
285 await act(async () => {
286 state.allowed = true;
287 for (const listener of state.listeners) listener();
288 });
289 state.choose.mockResolvedValue(action);
290 await openContextMenu();
291 expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({
292 environmentId: threadRef.environmentId,
293 input: {
294 threadId: threadRef.threadId,
295 linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null,
296 },
297 });
298 },
299);
apps/web/src/components/ChatMarkdown.test.tsx+14-5
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1// @vitest-environment jsdom1// @vitest-environment jsdom
22
3import { EnvironmentId } from "@t3tools/contracts";3import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
4import { act, type ComponentProps, type ReactNode } from "react";4import { act, type ComponentProps, type ReactNode } from "react";
5import { renderToStaticMarkup } from "react-dom/server";5import { renderToStaticMarkup } from "react-dom/server";
6import { create, type ReactTestRenderer } from "react-test-renderer";6import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -38,10 +38,19 @@ vi.mock("./ui/tooltip", async () => {...@@ -38,10 +38,19 @@ vi.mock("./ui/tooltip", async () => {
38});38});
39vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));39vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
40vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));40vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
41vi.mock("../state/session", async (importOriginal) => ({41vi.mock("../state/session", async (importOriginal) => {
42 ...(await importOriginal<typeof import("../state/session")>()),42 const actual = await importOriginal<typeof import("../state/session")>();
43 usePreparedConnection: () => ({ _tag: "Loading" }),43 const { AuthStandardClientScopes } = await import("@t3tools/contracts");
44}));44 const grantedScopes = new Set<AuthEnvironmentScope>(AuthStandardClientScopes);
45 const hasScope = (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) =>
46 environmentId !== null && grantedScopes.has(scope);
47 return {
48 ...actual,
49 useEnvironmentScope: hasScope,
50 readEnvironmentScope: hasScope,
51 usePreparedConnection: () => ({ _tag: "Loading" }),
52 };
53});
45vi.mock("../state/entities", () => ({54vi.mock("../state/entities", () => ({
46 readThreadShell: () => null,55 readThreadShell: () => null,
47 useProjects: () => [],56 useProjects: () => [],
apps/web/src/components/ChatMarkdown.tsx+20-8
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
3import {4import {
4 COMPOSER_CONTEXT_CLIPBOARD_MIME,5 COMPOSER_CONTEXT_CLIPBOARD_MIME,
...@@ -171,7 +172,7 @@ import { readThreadShell, useProjects } from "../state/entities";...@@ -171,7 +172,7 @@ import { readThreadShell, useProjects } from "../state/entities";
171import { serverEnvironment } from "../state/server";172import { serverEnvironment } from "../state/server";
172import { shellEnvironment } from "../state/shell";173import { shellEnvironment } from "../state/shell";
173import { assetEnvironment } from "../state/assets";174import { assetEnvironment } from "../state/assets";
174import { usePreparedConnection } from "../state/session";175import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session";
175import { previewEnvironment } from "../state/preview";176import { previewEnvironment } from "../state/preview";
176import { useAtomCommand } from "../state/use-atom-command";177import { useAtomCommand } from "../state/use-atom-command";
177import { useAtomQueryRunner } from "../state/use-atom-query-runner";178import { useAtomQueryRunner } from "../state/use-atom-query-runner";
...@@ -2516,12 +2517,11 @@ function useChatMarkdownState({...@@ -2516,12 +2517,11 @@ function useChatMarkdownState({
2516 });2517 });
2517 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);2518 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
2518 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;2519 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
2520 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
2519 const remoteOpen = useRemoteOpenResolution(environmentId);2521 const remoteOpen = useRemoteOpenResolution(environmentId);
2520 const canUseShellActions = canUseMarkdownFileShellActions(2522 const canUseShellActions =
2521 environmentId,2523 canOperateHost &&
2522 remoteOpen.state.mode,2524 canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved);
2523 remoteOpen.isResolved,
2524 );
2525 const preparedConnection = usePreparedConnection(environmentId);2525 const preparedConnection = usePreparedConnection(environmentId);
2526 const openMarkdownMedia = useCallback(2526 const openMarkdownMedia = useCallback(
2527 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {2527 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {
...@@ -2591,6 +2591,13 @@ function useChatMarkdownState({...@@ -2591,6 +2591,13 @@ function useChatMarkdownState({
2591 ),2591 ),
2592 );2592 );
2593 }2593 }
2594 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
2595 return Promise.resolve(
2596 AsyncResult.failure<void, Error>(
2597 Cause.fail(new Error("This connection cannot reveal files on this environment.")),
2598 ),
2599 );
2600 }
2594 return openInEditor({2601 return openInEditor({
2595 environmentId,2602 environmentId,
2596 input: { cwd: filePath, editor: "file-manager", reveal: true },2603 input: { cwd: filePath, editor: "file-manager", reveal: true },
...@@ -2687,6 +2694,7 @@ function useChatMarkdownState({...@@ -2687,6 +2694,7 @@ function useChatMarkdownState({
2687 const updateThreadPullRequestLink = useCallback(2694 const updateThreadPullRequestLink = useCallback(
2688 async (href: string, linked: boolean) => {2695 async (href: string, linked: boolean) => {
2689 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;2696 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2697 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
2690 await pullRequestLinking.changeLink(threadRef, href, linked);2698 await pullRequestLinking.changeLink(threadRef, href, linked);
2691 },2699 },
2692 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],2700 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
...@@ -2878,6 +2886,7 @@ function useChatMarkdownState({...@@ -2878,6 +2886,7 @@ function useChatMarkdownState({
28782886
2879 const componentState = useMemo(2887 const componentState = useMemo(
2880 () => ({2888 () => ({
2889 canOperateHost,
2881 cwd,2890 cwd,
2882 diffThemeName,2891 diffThemeName,
2883 environmentId,2892 environmentId,
...@@ -2909,6 +2918,7 @@ function useChatMarkdownState({...@@ -2909,6 +2918,7 @@ function useChatMarkdownState({
2909 updateThreadPullRequestLink,2918 updateThreadPullRequestLink,
2910 }),2919 }),
2911 [2920 [
2921 canOperateHost,
2912 cwd,2922 cwd,
2913 diffThemeName,2923 diffThemeName,
2914 environmentId,2924 environmentId,
...@@ -3063,6 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3063,6 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
3063 },3073 },
3064 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {3074 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
3065 const {3075 const {
3076 canOperateHost,
3066 cwd,3077 cwd,
3067 environmentId,3078 environmentId,
3068 imageBaseDir,3079 imageBaseDir,
...@@ -3203,8 +3214,9 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3203,8 +3214,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
3203 event.stopPropagation();3214 event.stopPropagation();
3204 const api = readLocalApi();3215 const api = readLocalApi();
3205 if (!api) return;3216 if (!api) return;
3206 const threadLinkAction =3217 const threadLinkAction = !canOperateHost
3207 linkedThreadPullRequestFor(href) !== null3218 ? undefined
3219 : linkedThreadPullRequestFor(href) !== null
3208 ? "unlink-from-thread"3220 ? "unlink-from-thread"
3209 : resolveThreadPullRequest(href) === null3221 : resolveThreadPullRequest(href) === null
3210 ? undefined3222 ? undefined
apps/web/src/components/ChatView.tsx+161-52
...@@ -48,12 +48,15 @@ import {...@@ -48,12 +48,15 @@ import {
48} from "../questionAttachments";48} from "../questionAttachments";
49import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";49import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
50import {50import {
51 AuthOrchestrationOperateScope,
52 AuthSettingsWriteScope,
51 type AssistantCitation,53 type AssistantCitation,
52 type ChatFileAttachment,54 type ChatFileAttachment,
53 CommandId,55 CommandId,
54 DEFAULT_MODEL,56 DEFAULT_MODEL,
55 isProviderNativeSubagentThread,57 isProviderNativeSubagentThread,
56 type ChatAttachment as ContractChatAttachment,58 type ChatAttachment as ContractChatAttachment,
59 EnvironmentAuthorizationError,
57 type EnvironmentId,60 type EnvironmentId,
58 type MessageId,61 type MessageId,
59 type ModelSelection,62 type ModelSelection,
...@@ -285,9 +288,10 @@ import { DeviceSetup } from "./device/DeviceSetup";...@@ -285,9 +288,10 @@ import { DeviceSetup } from "./device/DeviceSetup";
285import { Dialog } from "./ui/dialog";288import { Dialog } from "./ui/dialog";
286import { WizardPopup } from "./ui/wizard";289import { WizardPopup } from "./ui/wizard";
287import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";290import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
288import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
289import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";291import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
290import { isEditableFocused } from "../lib/editableFocus";292import { isEditableFocused } from "../lib/editableFocus";
293import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
294import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings";
291import ThreadTerminalDrawer from "./ThreadTerminalDrawer";295import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
292import {296import {
293 AlarmClockIcon,297 AlarmClockIcon,
...@@ -555,8 +559,13 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at...@@ -555,8 +559,13 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at
555import { appAtomRegistry } from "../rpc/atomRegistry";559import { appAtomRegistry } from "../rpc/atomRegistry";
556import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";560import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
557import { assetEnvironment } from "../state/assets";561import { assetEnvironment } from "../state/assets";
558import { readPreparedConnection } from "../state/session";562import {
563 readEnvironmentScope,
564 readPreparedConnection,
565 useEnvironmentScope,
566} from "../state/session";
559import { useAtomCommand } from "../state/use-atom-command";567import { useAtomCommand } from "../state/use-atom-command";
568import { useOrchestrationCommand } from "../state/use-orchestration-command";
560import { useAtomQueryRunner } from "../state/use-atom-query-runner";569import { useAtomQueryRunner } from "../state/use-atom-query-runner";
561import { Button, InlineButton } from "./ui/button";570import { Button, InlineButton } from "./ui/button";
562import {571import {
...@@ -1535,6 +1544,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1535,6 +1544,7 @@ export default function ChatView(props: ChatViewProps) {
1535 reserveTitleBarControlInset = true,1544 reserveTitleBarControlInset = true,
1536 forceExpandedMobileComposer = false,1545 forceExpandedMobileComposer = false,
1537 } = props;1546 } = props;
1547 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
1538 const draftId = routeKind === "draft" ? props.draftId : null;1548 const draftId = routeKind === "draft" ? props.draftId : null;
1539 const handleNewThread = useNewThreadHandler();1549 const handleNewThread = useNewThreadHandler();
1540 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();1550 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
...@@ -1562,46 +1572,48 @@ export default function ChatView(props: ChatViewProps) {...@@ -1562,46 +1572,48 @@ export default function ChatView(props: ChatViewProps) {
1562 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");1572 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
1563 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");1573 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
1564 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");1574 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
1565 const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false });1575 const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false });
1566 const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false });1576 const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false });
1567 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1577 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
1568 reportFailure: false,1578 reportFailure: false,
1569 });1579 });
1570 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });1580 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
1571 const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, {1581 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
1582 reportFailure: false,
1583 });
1584 const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, {
1572 reportFailure: false,1585 reportFailure: false,
1573 });1586 });
1574 const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {1587 const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
1575 reportFailure: false,1588 reportFailure: false,
1576 });1589 });
1577 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });1590 const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, {
1578 const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, {
1579 reportFailure: false,1591 reportFailure: false,
1580 });1592 });
1581 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {1593 const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, {
1582 reportFailure: false,1594 reportFailure: false,
1583 });1595 });
1584 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1596 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1585 reportFailure: false,1597 reportFailure: false,
1586 refresh: true,1598 refresh: true,
1587 });1599 });
1588 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {1600 const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, {
1589 reportFailure: false,1601 reportFailure: false,
1590 });1602 });
1591 const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {1603 const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
1592 label: "load earlier thread history",1604 label: "load earlier thread history",
1593 reportFailure: false,1605 reportFailure: false,
1594 });1606 });
1595 const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, {1607 const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, {
1596 reportFailure: false,1608 reportFailure: false,
1597 });1609 });
1598 const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, {1610 const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, {
1599 reportFailure: false,1611 reportFailure: false,
1600 });1612 });
1601 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {1613 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
1602 reportFailure: false,1614 reportFailure: false,
1603 });1615 });
1604 const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {1616 const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
1605 reportFailure: false,1617 reportFailure: false,
1606 });1618 });
1607 const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {1619 const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {
...@@ -3228,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3228,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) {
3228 providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ??3240 providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ??
3229 activeProviderStatus;3241 activeProviderStatus;
3230 const supportsConversationRollback =3242 const supportsConversationRollback =
3243 canOperateThread &&
3231 conversationProviderStatus !== null &&3244 conversationProviderStatus !== null &&
3232 conversationProviderStatus.supportsConversationRollback !== false;3245 conversationProviderStatus.supportsConversationRollback !== false;
3233 const phase = derivePhase(activeRuntime);3246 const phase = derivePhase(activeRuntime);
...@@ -4065,6 +4078,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -4065,6 +4078,9 @@ export default function ChatView(props: ChatViewProps) {
4065 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,4078 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
4066 });4079 });
4067 const keybindings = useAtomValue(primaryServerKeybindingsAtom);4080 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
4081 const scriptKeybindings =
4082 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
4083 DEFAULT_RESOLVED_KEYBINDINGS;
4068 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);4084 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
4069 const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId);4085 const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId);
4070 const showOpenInPicker = shouldShowOpenInPicker({4086 const showOpenInPicker = shouldShowOpenInPicker({
...@@ -4497,12 +4513,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -4497,12 +4513,14 @@ export default function ChatView(props: ChatViewProps) {
4497 const focusComposer = useCallback(() => {4513 const focusComposer = useCallback(() => {
4498 composerRef.current?.focusAtEnd();4514 composerRef.current?.focusAtEnd();
4499 }, [composerRef]);4515 }, [composerRef]);
4500 const canInterruptRunningThread = deriveCanInterruptRunningThread(4516 const canInterruptRunningThread =
4501 activeThread !== undefined,4517 canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
4502 activeRuntime,
4503 );
4504 const onInterrupt = useCallback(async () => {4518 const onInterrupt = useCallback(async () => {
4505 if (!activeThread) return;4519 if (
4520 !activeThread ||
4521 !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope)
4522 )
4523 return;
4506 const result = await interruptThreadTurn({4524 const result = await interruptThreadTurn({
4507 environmentId,4525 environmentId,
4508 input: { threadId: activeThread.id },4526 input: { threadId: activeThread.id },
...@@ -4997,9 +5015,37 @@ export default function ChatView(props: ChatViewProps) {...@@ -4997,9 +5015,37 @@ export default function ChatView(props: ChatViewProps) {
4997 projectCwd: string;5015 projectCwd: string;
4998 previousScripts: ReadonlyArray<ProjectScript>;5016 previousScripts: ReadonlyArray<ProjectScript>;
4999 nextScripts: ReadonlyArray<ProjectScript>;5017 nextScripts: ReadonlyArray<ProjectScript>;
5000 keybinding?: string | null;5018 keybinding: NewProjectScriptInput["keybinding"];
5001 keybindingCommand: KeybindingCommand | null;5019 keybindingCommand: KeybindingCommand | null;
5002 }): Promise<AtomCommandResult<void, unknown>> => {5020 }): Promise<AtomCommandResult<void, unknown>> => {
5021 const previousKeybinding = keybindingValueForCommand(
5022 appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [],
5023 input.keybindingCommand,
5024 );
5025 const isDeletingScript = !input.nextScripts.some(
5026 (script) => commandForProjectScript(script.id) === input.keybindingCommand,
5027 );
5028 const changesKeybinding =
5029 isElectron &&
5030 input.keybinding !== undefined &&
5031 (input.keybinding?.trim() || null) !== previousKeybinding &&
5032 (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
5033 if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
5034 return AsyncResult.failure(
5035 Cause.fail(
5036 new EnvironmentAuthorizationError({
5037 requiredScope: AuthSettingsWriteScope,
5038 message: "This connection cannot change keyboard shortcuts.",
5039 }),
5040 ),
5041 );
5042 }
5043 const keybindingRule = changesKeybinding
5044 ? decodeProjectScriptKeybindingRule({
5045 keybinding: input.keybinding,
5046 command: input.keybindingCommand,
5047 })
5048 : null;
5003 const updateResult = mapAtomCommandResult(5049 const updateResult = mapAtomCommandResult(
5004 await updateProjectScriptSettings({5050 await updateProjectScriptSettings({
5005 environmentId,5051 environmentId,
...@@ -5028,10 +5074,19 @@ export default function ChatView(props: ChatViewProps) {...@@ -5028,10 +5074,19 @@ export default function ChatView(props: ChatViewProps) {
5028 return updateResult;5074 return updateResult;
5029 }5075 }
50305076
5031 const keybindingRule = decodeProjectScriptKeybindingRule({5077 if (!changesKeybinding) return updateResult;
5032 keybinding: input.keybinding,5078 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
5033 command: input.keybindingCommand,5079 return AsyncResult.failure(
5034 });5080 Cause.fail(
5081 new EnvironmentAuthorizationError({
5082 requiredScope: AuthSettingsWriteScope,
5083 message: isDeletingScript
5084 ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed."
5085 : "The script was saved, but this connection can no longer change keyboard shortcuts.",
5086 }),
5087 ),
5088 );
5089 }
50355090
5036 if (!isElectron) return updateResult;5091 if (!isElectron) return updateResult;
50375092
...@@ -6991,7 +7046,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6991,7 +7046,7 @@ export default function ChatView(props: ChatViewProps) {
6991 ]);7046 ]);
6992 const activeThreadSettled =7047 const activeThreadSettled =
6993 supportsSettlement && activeThreadShell?.settledOverride === "settled";7048 supportsSettlement && activeThreadShell?.settledOverride === "settled";
6994 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {7049 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
6995 reportFailure: false,7050 reportFailure: false,
6996 });7051 });
6997 // Keyed by thread, not a boolean: the pending state must follow the thread7052 // Keyed by thread, not a boolean: the pending state must follow the thread
...@@ -7000,7 +7055,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -7000,7 +7055,11 @@ export default function ChatView(props: ChatViewProps) {
7000 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);7055 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);
7001 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;7056 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
7002 const handleUnsettleActiveThread = useCallback(async () => {7057 const handleUnsettleActiveThread = useCallback(async () => {
7003 if (!activeThreadRef) return;7058 if (
7059 !activeThreadRef ||
7060 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
7061 )
7062 return;
7004 const threadKey = scopedThreadKey(activeThreadRef);7063 const threadKey = scopedThreadKey(activeThreadRef);
7005 setUnsettlingThreadKey(threadKey);7064 setUnsettlingThreadKey(threadKey);
7006 try {7065 try {
...@@ -7022,13 +7081,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -7022,13 +7081,17 @@ export default function ChatView(props: ChatViewProps) {
7022 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));7081 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
7023 }7082 }
7024 }, [activeThreadRef, unsettleThreadMutation]);7083 }, [activeThreadRef, unsettleThreadMutation]);
7025 const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, {7084 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
7026 reportFailure: false,7085 reportFailure: false,
7027 });7086 });
7028 const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null);7087 const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null);
7029 const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;7088 const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;
7030 const handleUnsnoozeActiveThread = useCallback(async () => {7089 const handleUnsnoozeActiveThread = useCallback(async () => {
7031 if (!activeThreadRef) return;7090 if (
7091 !activeThreadRef ||
7092 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
7093 )
7094 return;
7032 const threadKey = scopedThreadKey(activeThreadRef);7095 const threadKey = scopedThreadKey(activeThreadRef);
7033 setUnsnoozingThreadKey(threadKey);7096 setUnsnoozingThreadKey(threadKey);
7034 try {7097 try {
...@@ -7175,7 +7238,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7175,7 +7238,8 @@ export default function ChatView(props: ChatViewProps) {
7175 const isStoppingBackgroundWork =7238 const isStoppingBackgroundWork =
7176 stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;7239 stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;
7177 const handleStopBackgroundWork = useCallback(async () => {7240 const handleStopBackgroundWork = useCallback(async () => {
7178 if (!activeThread) return;7241 if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
7242 return;
7179 const requestKey = `${environmentId}:${activeThread.id}`;7243 const requestKey = `${environmentId}:${activeThread.id}`;
7180 setStoppingBackgroundWorkKey(requestKey);7244 setStoppingBackgroundWorkKey(requestKey);
7181 const result = await interruptThreadTurn({7245 const result = await interruptThreadTurn({
...@@ -7377,7 +7441,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7377,7 +7441,7 @@ export default function ChatView(props: ChatViewProps) {
7377 <Button7441 <Button
7378 size="xs"7442 size="xs"
7379 variant="ghost"7443 variant="ghost"
7380 disabled={isStoppingBackgroundWork}7444 disabled={!canOperateThread || isStoppingBackgroundWork}
7381 onClick={() => void handleStopBackgroundWork()}7445 onClick={() => void handleStopBackgroundWork()}
7382 >7446 >
7383 {isStoppingBackgroundWork ? "Stopping..." : "Stop"}7447 {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
...@@ -7387,6 +7451,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7387,6 +7451,7 @@ export default function ChatView(props: ChatViewProps) {
7387 }, [7451 }, [
7388 activeBackgroundTasks,7452 activeBackgroundTasks,
7389 activeThread,7453 activeThread,
7454 canOperateThread,
7390 handleStopBackgroundWork,7455 handleStopBackgroundWork,
7391 isStoppingBackgroundWork,7456 isStoppingBackgroundWork,
7392 onOpenRelatedThread,7457 onOpenRelatedThread,
...@@ -7423,7 +7488,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7423,7 +7488,7 @@ export default function ChatView(props: ChatViewProps) {
7423 <Button7488 <Button
7424 size="xs"7489 size="xs"
7425 variant="ghost"7490 variant="ghost"
7426 disabled={isSnoozed ? isUnsnoozing : isUnsettling}7491 disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)}
7427 onClick={() =>7492 onClick={() =>
7428 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())7493 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
7429 }7494 }
...@@ -7442,6 +7507,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7442,6 +7507,7 @@ export default function ChatView(props: ChatViewProps) {
7442 activeThread?.id,7507 activeThread?.id,
7443 activeThreadSettled,7508 activeThreadSettled,
7444 activeThreadSnoozed,7509 activeThreadSnoozed,
7510 canOperateThread,
7445 handleUnsnoozeActiveThread,7511 handleUnsnoozeActiveThread,
7446 handleUnsettleActiveThread,7512 handleUnsettleActiveThread,
7447 isUnsnoozing,7513 isUnsnoozing,
...@@ -7463,6 +7529,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7463,6 +7529,7 @@ export default function ChatView(props: ChatViewProps) {
7463 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),7529 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
7464 );7530 );
7465 const compactThreadUnavailable =7531 const compactThreadUnavailable =
7532 !canOperateThread ||
7466 !activeThread ||7533 !activeThread ||
7467 !activeThreadHasCompactableConversation ||7534 !activeThreadHasCompactableConversation ||
7468 !activeProject ||7535 !activeProject ||
...@@ -7479,11 +7546,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -7479,11 +7546,13 @@ export default function ChatView(props: ChatViewProps) {
7479 showPlanFollowUpPrompt;7546 showPlanFollowUpPrompt;
7480 const compactDisabled = compactThreadUnavailable;7547 const compactDisabled = compactThreadUnavailable;
7481 const compactDisabledReason = compactDisabled7548 const compactDisabledReason = compactDisabled
7482 ? !activeProject7549 ? !canOperateThread
7483 ? "Choose a project before compacting"7550 ? "This connection cannot change threads."
7484 : !manualCompactionProviderAvailable7551 : !activeProject
7485 ? "Compaction is unavailable for this provider"7552 ? "Choose a project before compacting"
7486 : "Compacting is unavailable right now"7553 : !manualCompactionProviderAvailable
7554 ? "Compaction is unavailable for this provider"
7555 : "Compacting is unavailable right now"
7487 : null;7556 : null;
7488 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {7557 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7489 if (7558 if (
...@@ -7813,7 +7882,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7813,7 +7882,7 @@ export default function ChatView(props: ChatViewProps) {
7813 }7882 }
7814 }7883 }
78157884
7816 const command = resolveShortcutCommand(event, keybindings, {7885 const command = resolveChatShortcutCommand(event, keybindings, scriptKeybindings, {
7817 context: shortcutContext,7886 context: shortcutContext,
7818 });7887 });
7819 if (!command) return;7888 if (!command) return;
...@@ -7826,6 +7895,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7826,6 +7895,7 @@ export default function ChatView(props: ChatViewProps) {
7826 }7895 }
78277896
7828 if (command === "thread.settle") {7897 if (command === "thread.settle") {
7898 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
7829 event.preventDefault();7899 event.preventDefault();
7830 event.stopPropagation();7900 event.stopPropagation();
7831 if (!isServerThread || !activeThreadRef || !supportsSettlement) return;7901 if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
...@@ -7849,6 +7919,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7849,6 +7919,7 @@ export default function ChatView(props: ChatViewProps) {
7849 }7919 }
78507920
7851 if (command === "thread.pin") {7921 if (command === "thread.pin") {
7922 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
7852 event.preventDefault();7923 event.preventDefault();
7853 event.stopPropagation();7924 event.stopPropagation();
7854 if (!isServerThread || !activeThreadRef || !supportsPinning) return;7925 if (!isServerThread || !activeThreadRef || !supportsPinning) return;
...@@ -8069,6 +8140,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8069,6 +8140,7 @@ export default function ChatView(props: ChatViewProps) {
8069 splitTerminal,8140 splitTerminal,
8070 splitPanelTerminal,8141 splitPanelTerminal,
8071 keybindings,8142 keybindings,
8143 scriptKeybindings,
8072 handleUnsettleActiveThread,8144 handleUnsettleActiveThread,
8073 isServerThread,8145 isServerThread,
8074 onInterrupt,8146 onInterrupt,
...@@ -8143,7 +8215,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -8143,7 +8215,13 @@ export default function ChatView(props: ChatViewProps) {
8143 const onRevertToTurnCount = useCallback(8215 const onRevertToTurnCount = useCallback(
8144 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {8216 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
8145 const localApi = readLocalApi();8217 const localApi = readLocalApi();
8146 if (!localApi || !activeThread || isRevertingCheckpoint) return;8218 if (
8219 !localApi ||
8220 !activeThread ||
8221 isRevertingCheckpoint ||
8222 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
8223 )
8224 return;
8147 const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);8225 const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);
8148 const message = sourceMessage8226 const message = sourceMessage
8149 ? {8227 ? {
...@@ -8514,6 +8592,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8514,6 +8592,7 @@ export default function ChatView(props: ChatViewProps) {
8514 );8592 );
8515 };8593 };
8516 if (8594 if (
8595 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
8517 !activeThread ||8596 !activeThread ||
8518 isSendBusy ||8597 isSendBusy ||
8519 isConnecting ||8598 isConnecting ||
...@@ -9141,6 +9220,23 @@ export default function ChatView(props: ChatViewProps) {...@@ -9141,6 +9220,23 @@ export default function ChatView(props: ChatViewProps) {
9141 void dockTransition.catch(() => resolveDockStarted?.());9220 void dockTransition.catch(() => resolveDockStarted?.());
9142 await dockStarted;9221 await dockStarted;
9143 }9222 }
9223
9224 const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities();
9225 if (
9226 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
9227 attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null
9228 ) {
9229 sendInFlightRef.current = false;
9230 setThreadError(
9231 threadIdForSend,
9232 attachmentCapabilitiesBeforeDispatch.fileBlockReason ??
9233 "This connection cannot change threads.",
9234 );
9235 setDockedDraftHeroThreadKey((currentThreadKey) =>
9236 currentThreadKey === activeThreadKey ? null : currentThreadKey,
9237 );
9238 return;
9239 }
9144 beginLocalDispatch({9240 beginLocalDispatch({
9145 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),9241 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
9146 // Only a draft has a background submission to hide behind its hero.9242 // Only a draft has a background submission to hide behind its hero.
...@@ -9815,7 +9911,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -9815,7 +9911,8 @@ export default function ChatView(props: ChatViewProps) {
98159911
9816 const onRespondToApproval = useCallback(9912 const onRespondToApproval = useCallback(
9817 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {9913 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
9818 if (!activeThreadId) return;9914 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
9915 return;
9819 if (9916 if (
9820 pendingApprovals.find((approval) => approval.requestId === requestId)9917 pendingApprovals.find((approval) => approval.requestId === requestId)
9821 ?.responseCapability !== "live"9918 ?.responseCapability !== "live"
...@@ -9848,7 +9945,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -9848,7 +9945,8 @@ export default function ChatView(props: ChatViewProps) {
98489945
9849 const onRespondToUserInput = useCallback(9946 const onRespondToUserInput = useCallback(
9850 async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {9947 async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {
9851 if (!activeThreadId) return;9948 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
9949 return;
9852 const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);9950 const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
9853 if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;9951 if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
9854 const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);9952 const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
...@@ -10079,7 +10177,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -10079,7 +10177,14 @@ export default function ChatView(props: ChatViewProps) {
10079 context?: ReturnType<typeof buildMessageContext>;10177 context?: ReturnType<typeof buildMessageContext>;
10080 interactionMode: "default" | "plan";10178 interactionMode: "default" | "plan";
10081 }) {10179 }) {
10082 if (!activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current) {10180 if (
10181 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
10182 !activeThread ||
10183 !isServerThread ||
10184 isSendBusy ||
10185 isConnecting ||
10186 sendInFlightRef.current
10187 ) {
10083 return false;10188 return false;
10084 }10189 }
1008510190
...@@ -10213,6 +10318,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -10213,6 +10318,7 @@ export default function ChatView(props: ChatViewProps) {
1021310318
10214 const onImplementPlanInNewThread = useCallback(async () => {10319 const onImplementPlanInNewThread = useCallback(async () => {
10215 if (10320 if (
10321 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
10216 !activeThread ||10322 !activeThread ||
10217 !activeProject ||10323 !activeProject ||
10218 !activeProposedPlan ||10324 !activeProposedPlan ||
...@@ -11264,6 +11370,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11264,6 +11370,7 @@ export default function ChatView(props: ChatViewProps) {
11264 ) : null}11370 ) : null}
11265 {!composerMounted ? null : (11371 {!composerMounted ? null : (
11266 <ChatComposer11372 <ChatComposer
11373 canOperateThread={canOperateThread}
11267 reportedModelSelection={reportedModelSelection}11374 reportedModelSelection={reportedModelSelection}
11268 multipleModelSelections={multipleModelSelections}11375 multipleModelSelections={multipleModelSelections}
11269 supportsMultipleModels={11376 supportsMultipleModels={
...@@ -11301,17 +11408,19 @@ export default function ChatView(props: ChatViewProps) {...@@ -11301,17 +11408,19 @@ export default function ChatView(props: ChatViewProps) {
11301 canResume={resumableRunId !== null || hasHeldQueuedRuns}11408 canResume={resumableRunId !== null || hasHeldQueuedRuns}
11302 isRevertingCheckpoint={isRevertingCheckpoint}11409 isRevertingCheckpoint={isRevertingCheckpoint}
11303 sendDisabledReason={11410 sendDisabledReason={
11304 isEnvironmentChanging11411 !canOperateThread
11305 ? "Preparing machine"11412 ? "This connection cannot change threads."
11306 : isRevertingCheckpoint11413 : isEnvironmentChanging
11307 ? "Rewinding conversation"11414 ? "Preparing machine"
11308 : feedbackUploading11415 : isRevertingCheckpoint
11309 ? "Sending feedback"11416 ? "Rewinding conversation"
11310 : threadDetailLoading11417 : feedbackUploading
11311 ? "Messages loading"11418 ? "Sending feedback"
11312 : worktreeSetupBlocksSend11419 : threadDetailLoading
11313 ? "Preparing worktree"11420 ? "Messages loading"
11314 : projectCloneSendBlockReason11421 : worktreeSetupBlocksSend
11422 ? "Preparing worktree"
11423 : projectCloneSendBlockReason
11315 }11424 }
11316 isPreparingWorktree={isPreparingWorktree}11425 isPreparingWorktree={isPreparingWorktree}
11317 queuedRunsControl={11426 queuedRunsControl={
apps/web/src/components/KeybindingsConfigWarning.test.tsx created+119
...@@ -0,0 +1,119 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 AuthOrchestrationOperateScope,
4 EnvironmentId,
5 type AuthEnvironmentScope,
6 type AuthSessionState,
7} from "@t3tools/contracts";
8import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
9import { act, create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
11
12type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
13const state = vi.hoisted(() => ({
14 registry: null as AtomRegistry.AtomRegistry | null,
15 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
16 run: vi.fn(),
17 toast: vi.fn(),
18}));
19
20vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
21vi.mock("@t3tools/client-runtime/state/session", () => ({
22 createEnvironmentSessionAtoms: () => ({
23 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
24 }),
25}));
26vi.mock("../rpc/atomRegistry", () => ({
27 get appAtomRegistry() {
28 return state.registry;
29 },
30}));
31vi.mock("../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
32vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
33vi.mock("../hooks/useLocalStorage", () => ({
34 getLocalStorageItem: () => "vscode",
35 setLocalStorageItem: vi.fn(),
36 useLocalStorage: vi.fn(),
37}));
38vi.mock("./ui/button", () => ({ Button: "button" }));
39vi.mock("./ui/toast", () => ({
40 toastManager: { add: state.toast },
41 stackedThreadToast: (value: unknown) => value,
42}));
43
44import { KeybindingsConfigWarning } from "./KeybindingsConfigWarning";
45
46const environmentId = EnvironmentId.make("warning-environment");
47let renderer: ReactTestRenderer | undefined;
48const session = (scopes: readonly AuthEnvironmentScope[]): AuthSessionState => ({
49 authenticated: true,
50 scopes,
51 auth: {
52 policy: "remote-reachable",
53 bootstrapMethods: ["one-time-token"],
54 sessionMethods: ["bearer-access-token"],
55 sessionCookieName: "t3_session",
56 },
57});
58
59beforeEach(() => {
60 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
61 state.registry = AtomRegistry.make();
62 state.sessions.set(
63 environmentId,
64 Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe(
65 Atom.keepAlive,
66 ),
67 );
68 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
69 state.toast.mockReset();
70});
71
72afterEach(async () => {
73 await act(async () => renderer?.unmount());
74 renderer = undefined;
75 state.registry?.dispose();
76 state.sessions.clear();
77 vi.unstubAllGlobals();
78});
79
80it("updates a visible warning on revocation and blocks its retained action until regrant", async () => {
81 await act(async () => {
82 renderer = create(
83 <RegistryContext.Provider value={state.registry!}>
84 <KeybindingsConfigWarning
85 environmentId={environmentId}
86 configPath="/t3/keybindings.json"
87 availableEditors={["vscode"]}
88 message="Invalid shortcut"
89 />
90 </RegistryContext.Provider>,
91 );
92 });
93 const open = renderer!.root.findByType("button").props.onClick;
94 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
95 await act(async () => {
96 state.registry!.set(state.sessions.get(environmentId)!, AsyncResult.success(session([])));
97 await open();
98 });
99 expect(renderer!.root.findByType("button").props.disabled).toBe(true);
100 expect(state.run).not.toHaveBeenCalled();
101 expect(state.toast).toHaveBeenCalledWith(
102 expect.objectContaining({
103 title: "Unable to open keybindings file",
104 }),
105 );
106
107 await act(async () => {
108 state.registry!.set(
109 state.sessions.get(environmentId)!,
110 AsyncResult.success(session([AuthOrchestrationOperateScope])),
111 );
112 });
113 expect(renderer!.root.findByType("button").props.disabled).toBe(false);
114 await act(async () => open());
115 expect(state.run).toHaveBeenCalledExactlyOnceWith({
116 environmentId,
117 input: { cwd: "/t3/keybindings.json", editor: "vscode" },
118 });
119});
apps/web/src/components/KeybindingsConfigWarning.tsx created+54
...@@ -0,0 +1,54 @@
1import {
2 AuthOrchestrationOperateScope,
3 type EditorId,
4 type EnvironmentId,
5} from "@t3tools/contracts";
6import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
7
8import { useOpenInPreferredEditor } from "../editorPreferences";
9import { useEnvironmentScope } from "../state/session";
10import { Button } from "./ui/button";
11import { stackedThreadToast, toastManager } from "./ui/toast";
12
13export function KeybindingsConfigWarning({
14 message,
15 environmentId,
16 configPath,
17 availableEditors,
18}: {
19 message: string;
20 environmentId: EnvironmentId | null;
21 configPath: string | null;
22 availableEditors: readonly EditorId[];
23}) {
24 const canOpenEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
25 const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors);
26
27 return (
28 <>
29 {message}
30 <span className="mt-2 flex justify-end">
31 <Button
32 size="xs"
33 variant="outline"
34 disabled={!canOpenEditor || !configPath || availableEditors.length === 0}
35 onClick={async () => {
36 if (!configPath) return;
37 const result = await openInEditor(configPath);
38 if (result._tag === "Success") return;
39 const error = squashAtomCommandFailure(result);
40 toastManager.add(
41 stackedThreadToast({
42 type: "error",
43 title: "Unable to open keybindings file",
44 description: error instanceof Error ? error.message : "Unknown error opening file.",
45 }),
46 );
47 }}
48 >
49 Open keybindings.json
50 </Button>
51 </span>
52 </>
53 );
54}
apps/web/src/components/LegacySidebar.tsx+102-14
...@@ -45,7 +45,9 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-...@@ -45,7 +45,9 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-
45import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";45import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
46import { CSS } from "@dnd-kit/utilities";46import { CSS } from "@dnd-kit/utilities";
47import {47import {
48 AuthOrchestrationOperateScope,
48 type ContextMenuItem,49 type ContextMenuItem,
50 type EnvironmentId,
49 ProjectId,51 ProjectId,
50 type ScopedThreadRef,52 type ScopedThreadRef,
51 type ResolvedKeybindingsConfig,53 type ResolvedKeybindingsConfig,
...@@ -96,7 +98,12 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";...@@ -96,7 +98,12 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";
96import { useThreadDiscoveredPorts } from "../portDiscoveryState";98import { useThreadDiscoveredPorts } from "../portDiscoveryState";
97import { openDiscoveredPort } from "./preview/openDiscoveredPort";99import { openDiscoveredPort } from "./preview/openDiscoveredPort";
98import { useAtomCommand } from "../state/use-atom-command";100import { useAtomCommand } from "../state/use-atom-command";
101import {
102 ORCHESTRATION_OPERATE_DENIED_MESSAGE,
103 useOrchestrationCommand,
104} from "../state/use-orchestration-command";
99import { previewEnvironment } from "../state/preview";105import { previewEnvironment } from "../state/preview";
106import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
100import {107import {
101 legacyProjectCwdPreferenceKey,108 legacyProjectCwdPreferenceKey,
102 resolveProjectExpanded,109 resolveProjectExpanded,
...@@ -357,6 +364,18 @@ interface SidebarThreadRowProps {...@@ -357,6 +364,18 @@ interface SidebarThreadRowProps {
357 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;364 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
358}365}
359366
367function checkTaskPermission(environmentId: EnvironmentId): boolean {
368 if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true;
369 toastManager.add(
370 stackedThreadToast({
371 type: "error",
372 title: "Action unavailable",
373 description: ORCHESTRATION_OPERATE_DENIED_MESSAGE,
374 }),
375 );
376 return false;
377}
378
360const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {379const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
361 const {380 const {
362 orderedProjectThreadKeys,381 orderedProjectThreadKeys,
...@@ -385,6 +404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -385,6 +404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
385 onFileDropThreads,404 onFileDropThreads,
386 thread,405 thread,
387 } = props;406 } = props;
407 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
388 const threadRef = scopeThreadRef(thread.environmentId, thread.id);408 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
389 const threadKey = scopedThreadKey(threadRef);409 const threadKey = scopedThreadKey(threadRef);
390 const [isFileDragOver, setIsFileDragOver] = useState(false);410 const [isFileDragOver, setIsFileDragOver] = useState(false);
...@@ -485,10 +505,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -485,10 +505,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
485 : null;505 : null;
486 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);506 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
487 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);507 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
488 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;508 const isConfirmingArchive =
509 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
489 const threadMetaClassName = isConfirmingArchive510 const threadMetaClassName = isConfirmingArchive
490 ? "pointer-events-none opacity-0"511 ? "pointer-events-none opacity-0"
491 : !isThreadRunning512 : canOperateThread && !isThreadRunning
492 ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"513 ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0"
493 : "pointer-events-none";514 : "pointer-events-none";
494 const clearConfirmingArchive = useCallback(() => {515 const clearConfirmingArchive = useCallback(() => {
...@@ -517,6 +538,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -517,6 +538,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
517 );538 );
518 const handleRowDoubleClick = useCallback(539 const handleRowDoubleClick = useCallback(
519 (event: React.MouseEvent) => {540 (event: React.MouseEvent) => {
541 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return;
520 // Already renaming this row: a double-click on the row chrome (outside the542 // Already renaming this row: a double-click on the row chrome (outside the
521 // input) must not restart and discard the in-progress edit.543 // input) must not restart and discard the in-progress edit.
522 if (renamingThreadKey === threadKey) return;544 if (renamingThreadKey === threadKey) return;
...@@ -531,7 +553,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -531,7 +553,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
531 event.preventDefault();553 event.preventDefault();
532 startThreadRename(threadKey, thread.title);554 startThreadRename(threadKey, thread.title);
533 },555 },
534 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title],556 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title],
535 );557 );
536 const handleRowKeyDown = useCallback(558 const handleRowKeyDown = useCallback(
537 (event: React.KeyboardEvent) => {559 (event: React.KeyboardEvent) => {
...@@ -685,12 +707,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -685,12 +707,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
685 (event: React.MouseEvent<HTMLButtonElement>) => {707 (event: React.MouseEvent<HTMLButtonElement>) => {
686 event.preventDefault();708 event.preventDefault();
687 event.stopPropagation();709 event.stopPropagation();
710 if (!checkTaskPermission(thread.environmentId)) return;
688 setConfirmingArchiveThreadKey(threadKey);711 setConfirmingArchiveThreadKey(threadKey);
689 requestAnimationFrame(() => {712 requestAnimationFrame(() => {
690 confirmArchiveButtonRefs.current.get(threadKey)?.focus();713 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
691 });714 });
692 },715 },
693 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],716 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId],
694 );717 );
695 const handleArchiveImmediateClick = useCallback(718 const handleArchiveImmediateClick = useCallback(
696 (event: React.MouseEvent<HTMLButtonElement>) => {719 (event: React.MouseEvent<HTMLButtonElement>) => {
...@@ -775,7 +798,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -775,7 +798,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
775 </a>798 </a>
776 ) : null}799 ) : null}
777 {threadStatus && <ThreadStatusLabel status={threadStatus} />}800 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
778 {renamingThreadKey === threadKey ? (801 {canOperateThread && renamingThreadKey === threadKey ? (
779 <input802 <input
780 ref={handleRenameInputRef}803 ref={handleRenameInputRef}
781 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"804 className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"
...@@ -861,7 +884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP...@@ -861,7 +884,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
861 >884 >
862 Confirm885 Confirm
863 </button>886 </button>
864 ) : !isThreadRunning ? (887 ) : canOperateThread && !isThreadRunning ? (
865 appSettingsConfirmThreadArchive ? (888 appSettingsConfirmThreadArchive ? (
866 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">889 <div className="pointer-events-none absolute top-1/2 right-0.5 -translate-y-1/2 opacity-0 transition-opacity duration-150 max-sm:pointer-events-auto max-sm:opacity-100 group-hover/menu-sub-item:pointer-events-auto group-hover/menu-sub-item:opacity-100 group-focus-within/menu-sub-item:pointer-events-auto group-focus-within/menu-sub-item:opacity-100">
867 <button890 <button
...@@ -1191,13 +1214,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1191,13 +1214,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1191 (settings) => settings.confirmThreadArchive,1214 (settings) => settings.confirmThreadArchive,
1192 );1215 );
1193 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);1216 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1194 const deleteProject = useAtomCommand(projectEnvironment.delete, {1217 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
1195 reportFailure: false,1218 reportFailure: false,
1196 });1219 });
1197 const updateProject = useAtomCommand(projectEnvironment.update, {1220 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
1198 reportFailure: false,1221 reportFailure: false,
1199 });1222 });
1200 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {1223 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
1201 reportFailure: false,1224 reportFailure: false,
1202 });1225 });
1203 const updateSettings = useUpdateClientSettings();1226 const updateSettings = useUpdateClientSettings();
...@@ -1293,6 +1316,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1293,6 +1316,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1293 null,1316 null,
1294 );1317 );
1295 const [projectRenameTitle, setProjectRenameTitle] = useState("");1318 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1319 const canRenameProject = useEnvironmentScope(
1320 projectRenameTarget?.environmentId ?? null,
1321 AuthOrchestrationOperateScope,
1322 );
1296 const [projectGroupingTarget, setProjectGroupingTarget] =1323 const [projectGroupingTarget, setProjectGroupingTarget] =
1297 useState<SidebarProjectGroupMember | null>(null);1324 useState<SidebarProjectGroupMember | null>(null);
1298 const [projectGroupingSelection, setProjectGroupingSelection] = useState<1325 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
...@@ -1501,6 +1528,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1501,6 +1528,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1501 );1528 );
15021529
1503 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {1530 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1531 if (!checkTaskPermission(member.environmentId)) return;
1504 setProjectRenameTarget(member);1532 setProjectRenameTarget(member);
1505 setProjectRenameTitle(member.title);1533 setProjectRenameTitle(member.title);
1506 }, []);1534 }, []);
...@@ -1551,6 +1579,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1551,6 +1579,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15511579
1552 const handleRemoveProject = useCallback(1580 const handleRemoveProject = useCallback(
1553 async (member: SidebarProjectGroupMember) => {1581 async (member: SidebarProjectGroupMember) => {
1582 if (!checkTaskPermission(member.environmentId)) return;
1554 const api = readLocalApi();1583 const api = readLocalApi();
1555 if (!api) {1584 if (!api) {
1556 return;1585 return;
...@@ -1573,6 +1602,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1573,6 +1602,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1573 await new Promise<void>((resolve) => {1602 await new Promise<void>((resolve) => {
1574 window.setTimeout(resolve, 180);1603 window.setTimeout(resolve, 180);
1575 });1604 });
1605 if (!checkTaskPermission(member.environmentId)) return;
15761606
1577 const latestProjectThreads = Array.from(1607 const latestProjectThreads = Array.from(
1578 sidebarThreadByKeyRef.current.values(),1608 sidebarThreadByKeyRef.current.values(),
...@@ -1765,12 +1795,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1765,12 +1795,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17651795
1766 const clicked = await api.contextMenu.show(1796 const clicked = await api.contextMenu.show(
1767 [1797 [
1768 buildTargetedItem("rename", "Rename"),1798 buildTargetedItem("rename", "Rename", {
1799 isDisabled: (member) =>
1800 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
1801 }),
1769 buildTargetedItem("grouping", "Group into..."),1802 buildTargetedItem("grouping", "Group into..."),
1770 buildTargetedItem("copy-path", "Copy Path"),1803 buildTargetedItem("copy-path", "Copy Path"),
1771 { id: "project-settings", label: "Project settings", icon: "settings" },1804 { id: "project-settings", label: "Project settings", icon: "settings" },
1772 buildTargetedItem("delete", "Remove", {1805 buildTargetedItem("delete", "Remove", {
1773 destructive: true,1806 destructive: true,
1807 isDisabled: (member) =>
1808 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
1774 }),1809 }),
1775 ],1810 ],
1776 {1811 {
...@@ -1907,9 +1942,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1907,9 +1942,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1907 const hasRunningThread = selectedThreadEntries.some(1942 const hasRunningThread = selectedThreadEntries.some(
1908 ({ thread }) => !threadRuntimeCanArchive(thread.runtime),1943 ({ thread }) => !threadRuntimeCanArchive(thread.runtime),
1909 );1944 );
1945 const canOperateSelection = selectedThreadEntries.every(({ threadRef }) =>
1946 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
1947 );
19101948
1911 const clicked = await api.contextMenu.show(1949 const clicked = await api.contextMenu.show(
1912 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }),1950 buildMultiSelectThreadContextMenuItems({ count, hasRunningThread }).map((item) =>
1951 item.id === "archive" || item.id === "delete"
1952 ? { ...item, disabled: item.disabled || !canOperateSelection }
1953 : item,
1954 ),
1913 position,1955 position,
1914 );1956 );
19151957
...@@ -1921,6 +1963,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1921,6 +1963,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1921 return;1963 return;
1922 }1964 }
19231965
1966 if (
1967 (clicked === "archive" || clicked === "delete") &&
1968 !selectedThreadEntries.every(({ threadRef }) =>
1969 checkTaskPermission(threadRef.environmentId),
1970 )
1971 ) {
1972 return;
1973 }
1974
1924 if (clicked === "archive") {1975 if (clicked === "archive") {
1925 if (appSettingsConfirmThreadArchive) {1976 if (appSettingsConfirmThreadArchive) {
1926 const confirmed = await api.dialogs.confirm(1977 const confirmed = await api.dialogs.confirm(
...@@ -1928,6 +1979,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1928,6 +1979,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1928 );1979 );
1929 if (!confirmed) return;1980 if (!confirmed) return;
1930 }1981 }
1982 if (
1983 !selectedThreadEntries.every(({ threadRef }) =>
1984 checkTaskPermission(threadRef.environmentId),
1985 )
1986 ) {
1987 return;
1988 }
19311989
1932 const archiveOutcome = await archiveSelectedThreadEntries({1990 const archiveOutcome = await archiveSelectedThreadEntries({
1933 entries: selectedThreadEntries,1991 entries: selectedThreadEntries,
...@@ -1974,6 +2032,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1974,6 +2032,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1974 );2032 );
1975 if (!confirmed) return;2033 if (!confirmed) return;
1976 }2034 }
2035 if (
2036 !selectedThreadEntries.every(({ threadRef }) =>
2037 checkTaskPermission(threadRef.environmentId),
2038 )
2039 ) {
2040 return;
2041 }
19772042
1978 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({2043 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
1979 entries: selectedThreadEntries,2044 entries: selectedThreadEntries,
...@@ -2090,6 +2155,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2090,6 +2155,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
20902155
2091 const attemptArchiveThread = useCallback(2156 const attemptArchiveThread = useCallback(
2092 async (threadRef: ScopedThreadRef) => {2157 async (threadRef: ScopedThreadRef) => {
2158 if (!checkTaskPermission(threadRef.environmentId)) return;
2093 const result = await archiveThread(threadRef);2159 const result = await archiveThread(threadRef);
2094 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {2160 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2095 const error = squashAtomCommandFailure(result);2161 const error = squashAtomCommandFailure(result);
...@@ -2111,6 +2177,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2111,6 +2177,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2111 }, []);2177 }, []);
21122178
2113 const startThreadRename = useCallback((threadKey: string, title: string) => {2179 const startThreadRename = useCallback((threadKey: string, title: string) => {
2180 const threadRef = parseScopedThreadKey(threadKey);
2181 if (!threadRef || !checkTaskPermission(threadRef.environmentId)) return;
2114 setRenamingThreadKey(threadKey);2182 setRenamingThreadKey(threadKey);
2115 setRenamingTitle(title);2183 setRenamingTitle(title);
2116 renamingCommittedRef.current = false;2184 renamingCommittedRef.current = false;
...@@ -2250,17 +2318,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2250,17 +2318,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2250 );2318 );
2251 const threadWorkspacePath =2319 const threadWorkspacePath =
2252 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;2320 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2321 const canOperateThread = readEnvironmentScope(
2322 thread.environmentId,
2323 AuthOrchestrationOperateScope,
2324 );
2253 const clicked = await api.contextMenu.show(2325 const clicked = await api.contextMenu.show(
2254 [2326 [
2255 ...(thread.branch2327 ...(thread.branch
2256 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]2328 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
2257 : []),2329 : []),
2258 { id: "rename", label: "Rename thread" },2330 { id: "rename", label: "Rename thread", disabled: !canOperateThread },
2259 { id: "mark-unread", label: "Mark unread" },2331 { id: "mark-unread", label: "Mark unread" },
2260 { id: "copy-path", label: "Copy Path" },2332 { id: "copy-path", label: "Copy Path" },
2261 { id: "copy-thread-id", label: "Copy Thread ID" },2333 { id: "copy-thread-id", label: "Copy Thread ID" },
2262 { id: "project-settings", label: "Project settings" },2334 { id: "project-settings", label: "Project settings" },
2263 { id: "delete", label: "Delete", destructive: true, icon: "trash" },2335 {
2336 id: "delete",
2337 label: "Delete",
2338 destructive: true,
2339 icon: "trash",
2340 disabled: !canOperateThread,
2341 },
2264 ],2342 ],
2265 position,2343 position,
2266 );2344 );
...@@ -2326,6 +2404,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2326,6 +2404,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2326 return;2404 return;
2327 }2405 }
2328 if (clicked !== "delete") return;2406 if (clicked !== "delete") return;
2407 if (!checkTaskPermission(threadRef.environmentId)) return;
2329 if (appSettingsConfirmThreadDelete) {2408 if (appSettingsConfirmThreadDelete) {
2330 const confirmed = await api.dialogs.confirm(2409 const confirmed = await api.dialogs.confirm(
2331 [2410 [
...@@ -2338,6 +2417,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2338,6 +2417,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2338 return;2417 return;
2339 }2418 }
2340 }2419 }
2420 if (!checkTaskPermission(threadRef.environmentId)) return;
2341 const result = await deleteThread(threadRef);2421 const result = await deleteThread(threadRef);
2342 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {2422 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2343 const error = squashAtomCommandFailure(result);2423 const error = squashAtomCommandFailure(result);
...@@ -2533,6 +2613,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2533,6 +2613,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2533 <span className="text-xs font-medium text-foreground">Project title</span>2613 <span className="text-xs font-medium text-foreground">Project title</span>
2534 <Input2614 <Input
2535 aria-label="Project title"2615 aria-label="Project title"
2616 disabled={!canRenameProject}
2536 value={projectRenameTitle}2617 value={projectRenameTitle}
2537 onChange={(event) => setProjectRenameTitle(event.target.value)}2618 onChange={(event) => setProjectRenameTitle(event.target.value)}
2538 onKeyDown={(event) => {2619 onKeyDown={(event) => {
...@@ -2548,12 +2629,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2548,12 +2629,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2548 Environment: {projectRenameTarget.environmentLabel}2629 Environment: {projectRenameTarget.environmentLabel}
2549 </p>2630 </p>
2550 ) : null}2631 ) : null}
2632 {!canRenameProject ? (
2633 <p className="text-xs text-muted-foreground">
2634 This connection cannot change projects.
2635 </p>
2636 ) : null}
2551 </DialogPanel>2637 </DialogPanel>
2552 <DialogFooter>2638 <DialogFooter>
2553 <Button variant="outline" onClick={closeProjectRenameDialog}>2639 <Button variant="outline" onClick={closeProjectRenameDialog}>
2554 Cancel2640 Cancel
2555 </Button>2641 </Button>
2556 <Button onClick={() => void submitProjectRename()}>Save</Button>2642 <Button disabled={!canRenameProject} onClick={() => void submitProjectRename()}>
2643 Save
2644 </Button>
2557 </DialogFooter>2645 </DialogFooter>
2558 </DialogPopup>2646 </DialogPopup>
2559 </Dialog>2647 </Dialog>
apps/web/src/components/ProjectScriptsControl.tsx+17-7
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";1import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import type {2import {
3 ProjectScript,3 AuthSettingsWriteScope,
4 ResolvedKeybindingsConfig,4 type EnvironmentId,
5 T3ProjectFileScript,5 type ProjectScript,
6 type T3ProjectFileScript,
7 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
7import {11import {
8 isAtomCommandInterrupted,12 isAtomCommandInterrupted,
9 squashAtomCommandFailure,13 squashAtomCommandFailure,
...@@ -13,6 +17,8 @@ import React, { useCallback, useMemo, useState } from "react";...@@ -13,6 +17,8 @@ import React, { useCallback, useMemo, useState } from "react";
1317
14import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";18import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
15import { shortcutLabelForCommand } from "~/keybindings";19import { shortcutLabelForCommand } from "~/keybindings";
20import { serverEnvironment } from "~/state/server";
21import { readEnvironmentScope } from "~/state/session";
16import {22import {
17 EMPTY_PROJECT_SCRIPT_INPUT,23 EMPTY_PROJECT_SCRIPT_INPUT,
18 editorRequestForScript,24 editorRequestForScript,
...@@ -55,10 +61,10 @@ interface ProjectScriptsControlProps {...@@ -55,10 +61,10 @@ interface ProjectScriptsControlProps {
55 displayMode?: "toolbar" | "panel";61 displayMode?: "toolbar" | "panel";
56 presentation?: "toolbar" | "menu";62 presentation?: "toolbar" | "menu";
57 onRequestMenuClose?: () => void;63 onRequestMenuClose?: () => void;
64 environmentId: EnvironmentId;
58 scripts: ReadonlyArray<ProjectScript>;65 scripts: ReadonlyArray<ProjectScript>;
59 /** Scripts declared in the project's checked-in t3.json, offered for import. */66 /** Scripts declared in the project's checked-in t3.json, offered for import. */
60 fileScripts?: ReadonlyArray<T3ProjectFileScript>;67 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
61 keybindings: ResolvedKeybindingsConfig;
62 preferredScriptId?: string | null;68 preferredScriptId?: string | null;
63 onRunScript: (script: ProjectScript) => void;69 onRunScript: (script: ProjectScript) => void;
64 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;70 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
...@@ -73,9 +79,9 @@ export default function ProjectScriptsControl({...@@ -73,9 +79,9 @@ export default function ProjectScriptsControl({
73 displayMode = "toolbar",79 displayMode = "toolbar",
74 presentation = "toolbar",80 presentation = "toolbar",
75 onRequestMenuClose,81 onRequestMenuClose,
82 environmentId,
76 scripts,83 scripts,
77 fileScripts = NO_FILE_SCRIPTS,84 fileScripts = NO_FILE_SCRIPTS,
78 keybindings,
79 preferredScriptId = null,85 preferredScriptId = null,
80 onRunScript,86 onRunScript,
81 onAddScript,87 onAddScript,
...@@ -85,6 +91,9 @@ export default function ProjectScriptsControl({...@@ -85,6 +91,9 @@ export default function ProjectScriptsControl({
85 const isPanel = displayMode === "panel";91 const isPanel = displayMode === "panel";
86 const ActionGroup = isPanel ? "div" : Group;92 const ActionGroup = isPanel ? "div" : Group;
87 const panelAnchorRef = React.useRef<HTMLDivElement | null>(null);93 const panelAnchorRef = React.useRef<HTMLDivElement | null>(null);
94 const keybindings =
95 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
96 DEFAULT_RESOLVED_KEYBINDINGS;
88 const [actionsMenuOpen, setActionsMenuOpen] = useState({97 const [actionsMenuOpen, setActionsMenuOpen] = useState({
89 presentation,98 presentation,
90 scripts: false,99 scripts: false,
...@@ -138,7 +147,7 @@ export default function ProjectScriptsControl({...@@ -138,7 +147,7 @@ export default function ProjectScriptsControl({
138 icon: fileScript.icon ?? "play",147 icon: fileScript.icon ?? "play",
139 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,148 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
140 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,149 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
141 keybinding: null,150 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
142 previewUrl: fileScript.previewUrl ?? null,151 previewUrl: fileScript.previewUrl ?? null,
143 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,152 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
144 };153 };
...@@ -459,6 +468,7 @@ export default function ProjectScriptsControl({...@@ -459,6 +468,7 @@ export default function ProjectScriptsControl({
459 )}468 )}
460469
461 <ProjectScriptEditorDialog470 <ProjectScriptEditorDialog
471 environmentId={environmentId}
462 request={editorRequest}472 request={editorRequest}
463 scripts={scripts}473 scripts={scripts}
464 onSubmit={submitScript}474 onSubmit={submitScript}
apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx+14
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import type { Dispatch, ReactElement, SetStateAction } from "react";1import type { Dispatch, ReactElement, SetStateAction } from "react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import {3import {
4 AuthProvidersManageScope,
5 type AuthEnvironmentScope,
4 type EnvironmentId,6 type EnvironmentId,
5 ProviderDriverKind,7 ProviderDriverKind,
6 ProviderInstanceId,8 ProviderInstanceId,
...@@ -16,6 +18,7 @@ import type {...@@ -16,6 +18,7 @@ import type {
1618
17const testState = vi.hoisted(() => ({19const testState = vi.hoisted(() => ({
18 groups: [] as LocalEnvironmentUpdateGroup[],20 groups: [] as LocalEnvironmentUpdateGroup[],
21 permittedEnvironmentIds: new Set<EnvironmentId>(),
19 updateProvider: vi.fn(),22 updateProvider: vi.fn(),
20}));23}));
2124
...@@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({...@@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({
90 serverEnvironment: { updateProvider: Symbol("updateProvider") },93 serverEnvironment: { updateProvider: Symbol("updateProvider") },
91}));94}));
9295
96vi.mock("~/state/session", () => ({
97 readEnvironmentScope: (environmentId: EnvironmentId, scope: AuthEnvironmentScope) =>
98 scope === AuthProvidersManageScope && testState.permittedEnvironmentIds.has(environmentId),
99 useEnvironmentScope: (environmentId: EnvironmentId | null, scope: AuthEnvironmentScope) =>
100 environmentId !== null &&
101 scope === AuthProvidersManageScope &&
102 testState.permittedEnvironmentIds.has(environmentId),
103}));
104
93vi.mock("~/state/use-atom-command", () => ({105vi.mock("~/state/use-atom-command", () => ({
94 useAtomCommand: () => testState.updateProvider,106 useAtomCommand: () => testState.updateProvider,
95}));107}));
...@@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => {...@@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => {
176 beforeEach(() => {188 beforeEach(() => {
177 vi.useFakeTimers();189 vi.useFakeTimers();
178 hooks.reset();190 hooks.reset();
191 testState.permittedEnvironmentIds.clear();
192 testState.permittedEnvironmentIds.add(environmentId);
179 testState.updateProvider.mockReset();193 testState.updateProvider.mockReset();
180 const candidate = provider() as ProviderUpdateCandidate;194 const candidate = provider() as ProviderUpdateCandidate;
181 testState.groups = [195 testState.groups = [
apps/web/src/components/ProviderUpdateEnvironmentRows.tsx+15-3
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { CheckIcon } from "lucide-react";1import { CheckIcon } from "lucide-react";
2import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";2import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
3import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";3import {
4 AuthProvidersManageScope,
5 type EnvironmentId,
6 type ServerProvider,
7} from "@t3tools/contracts";
4import {8import {
5 isAtomCommandInterrupted,9 isAtomCommandInterrupted,
6 squashAtomCommandFailure,10 squashAtomCommandFailure,
...@@ -9,6 +13,7 @@ import {...@@ -9,6 +13,7 @@ import {
913
10import { cn } from "~/lib/utils";14import { cn } from "~/lib/utils";
11import { serverEnvironment } from "~/state/server";15import { serverEnvironment } from "~/state/server";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
12import { useAtomCommand } from "~/state/use-atom-command";17import { useAtomCommand } from "~/state/use-atom-command";
13import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";18import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
14import {19import {
...@@ -115,6 +120,7 @@ function EnvironmentUpdateRow({...@@ -115,6 +120,7 @@ function EnvironmentUpdateRow({
115 readonly status: ProviderUpdateRowStatus;120 readonly status: ProviderUpdateRowStatus;
116 readonly onUpdate: () => void;121 readonly onUpdate: () => void;
117}) {122}) {
123 const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope);
118 let trailing: ReactNode;124 let trailing: ReactNode;
119 switch (status.kind) {125 switch (status.kind) {
120 case "loading":126 case "loading":
...@@ -126,14 +132,14 @@ function EnvironmentUpdateRow({...@@ -126,14 +132,14 @@ function EnvironmentUpdateRow({
126 case "failed":132 case "failed":
127 case "unchanged":133 case "unchanged":
128 trailing = (134 trailing = (
129 <Button size="xs" variant="outline" onClick={onUpdate}>135 <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}>
130 Retry136 Retry
131 </Button>137 </Button>
132 );138 );
133 break;139 break;
134 default:140 default:
135 trailing = (141 trailing = (
136 <Button size="xs" variant="outline" onClick={onUpdate}>142 <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}>
137 Update143 Update
138 </Button>144 </Button>
139 );145 );
...@@ -145,6 +151,11 @@ function EnvironmentUpdateRow({...@@ -145,6 +151,11 @@ function EnvironmentUpdateRow({
145 <div className="flex min-w-0 flex-col">151 <div className="flex min-w-0 flex-col">
146 <span className="truncate font-medium text-foreground">{group.label}</span>152 <span className="truncate font-medium text-foreground">{group.label}</span>
147 <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span>153 <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span>
154 {!canManageProviders ? (
155 <span className="text-xs text-muted-foreground">
156 This connection cannot manage provider accounts.
157 </span>
158 ) : null}
148 </div>159 </div>
149 <div className="shrink-0">{trailing}</div>160 <div className="shrink-0">{trailing}</div>
150 </div>161 </div>
...@@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({...@@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({
211222
212 const handleUpdate = useCallback(223 const handleUpdate = useCallback(
213 async (environmentId: EnvironmentId) => {224 async (environmentId: EnvironmentId) => {
225 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
214 const group = groupByEnvironment.get(environmentId);226 const group = groupByEnvironment.get(environmentId);
215 if (!group || group.candidates.length === 0) {227 if (!group || group.candidates.length === 0) {
216 return;228 return;
apps/web/src/components/ProviderUpdatePrimaryNotification.tsx+83-39
...@@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router";...@@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { DownloadIcon } from "lucide-react";3import { DownloadIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef } from "react";4import { useCallback, useEffect, useMemo, useRef } from "react";
5import { type ServerProvider, type ProviderInstanceId } from "@t3tools/contracts";5import {
6 AuthProvidersManageScope,
7 type ServerProvider,
8 type ProviderInstanceId,
9} from "@t3tools/contracts";
610
7import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";11import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
8import { usePrimaryEnvironment } from "../state/environments";12import { usePrimaryEnvironment } from "../state/environments";
13import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
9import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";14import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
10import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";15import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
11import {16import {
...@@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set<string>();...@@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set<string>();
27type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;32type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
2833
29type ActiveProviderUpdateToast =34type ActiveProviderUpdateToast =
30 | { readonly kind: "prompt"; readonly key: string; readonly toastId: ProviderUpdateToastId }35 | {
36 readonly kind: "prompt";
37 readonly key: string;
38 readonly toastId: ProviderUpdateToastId;
39 readonly canManageProviders: boolean;
40 }
31 | {41 | {
32 readonly kind: "update";42 readonly kind: "update";
33 readonly key: string;43 readonly key: string;
...@@ -100,6 +110,10 @@ export function ProviderUpdatePrimaryNotification() {...@@ -100,6 +110,10 @@ export function ProviderUpdatePrimaryNotification() {
100 const navigate = useNavigate();110 const navigate = useNavigate();
101 const providers = useAtomValue(primaryServerProvidersAtom);111 const providers = useAtomValue(primaryServerProvidersAtom);
102 const primaryEnvironment = usePrimaryEnvironment();112 const primaryEnvironment = usePrimaryEnvironment();
113 const canManageProviders = useEnvironmentScope(
114 primaryEnvironment?.environmentId ?? null,
115 AuthProvidersManageScope,
116 );
103 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {117 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
104 reportFailure: false,118 reportFailure: false,
105 });119 });
...@@ -178,11 +192,15 @@ export function ProviderUpdatePrimaryNotification() {...@@ -178,11 +192,15 @@ export function ProviderUpdatePrimaryNotification() {
178 activeToastRef.current = null;192 activeToastRef.current = null;
179 }193 }
180194
195 const updateExistingPrompt =
196 activeToast?.kind === "prompt" &&
197 activeToast.key === notificationKey &&
198 activeToast.canManageProviders !== canManageProviders;
181 if (199 if (
182 !notificationKey ||200 !notificationKey ||
183 dismissedNotificationKeys.has(notificationKey) ||201 dismissedNotificationKeys.has(notificationKey) ||
184 seenProviderUpdateNotificationKeys.has(notificationKey) ||202 (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) ||
185 activeToastRef.current203 (activeToastRef.current && !updateExistingPrompt)
186 ) {204 ) {
187 return;205 return;
188 }206 }
...@@ -199,7 +217,12 @@ export function ProviderUpdatePrimaryNotification() {...@@ -199,7 +217,12 @@ export function ProviderUpdatePrimaryNotification() {
199 };217 };
200218
201 const runUpdates = () => {219 const runUpdates = () => {
202 if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) {220 if (
221 updateStarted ||
222 oneClickProviders.length === 0 ||
223 !primaryEnvironment ||
224 !readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)
225 ) {
203 return;226 return;
204 }227 }
205 updateStarted = true;228 updateStarted = true;
...@@ -219,6 +242,19 @@ export function ProviderUpdatePrimaryNotification() {...@@ -219,6 +242,19 @@ export function ProviderUpdatePrimaryNotification() {
219 void (async () => {242 void (async () => {
220 const results = [];243 const results = [];
221 for (const provider of oneClickProviders) {244 for (const provider of oneClickProviders) {
245 if (!readEnvironmentScope(primaryEnvironment.environmentId, AuthProvidersManageScope)) {
246 if (activeToastRef.current === activeUpdate) {
247 addProviderUpdateToast({
248 view: getProviderUpdateRejectedToastView(
249 providerCount,
250 "This connection cannot manage provider accounts.",
251 ),
252 openSettings: openProviderSettings,
253 });
254 activeToastRef.current = null;
255 }
256 return;
257 }
222 results.push(258 results.push(
223 await updateProvider({259 await updateProvider({
224 environmentId: primaryEnvironment.environmentId,260 environmentId: primaryEnvironment.environmentId,
...@@ -260,44 +296,52 @@ export function ProviderUpdatePrimaryNotification() {...@@ -260,44 +296,52 @@ export function ProviderUpdatePrimaryNotification() {
260 })();296 })();
261 };297 };
262298
263 toastId = toastManager.add(299 const toastOptions = stackedThreadToast({
264 stackedThreadToast({300 type: initialView.type,
265 type: initialView.type,301 title: initialView.title,
266 title: initialView.title,302 description: canManageProviders
267 description: initialView.description,303 ? initialView.description
268 timeout: 0,304 : "This connection cannot manage provider accounts. View provider settings for update details.",
269 actionProps:305 timeout: 0,
270 oneClickProviders.length > 0306 actionProps:
271 ? {307 oneClickProviders.length > 0
272 children: "Update",308 ? {
273 onClick: runUpdates,309 children: "Update",
274 }310 disabled: !canManageProviders,
275 : {311 onClick: runUpdates,
312 }
313 : {
314 children: "Settings",
315 onClick: openSettings,
316 },
317 actionVariant: "outline",
318 data: {
319 leadingIcon:
320 updateProviders.length === 1 ? (
321 <ProviderUpdateToastIcon provider={updateProviders[0]!} />
322 ) : undefined,
323 hideCopyButton: true,
324 onClose: dismissPrompt,
325 ...(oneClickProviders.length > 0
326 ? {
327 secondaryActionProps: {
276 children: "Settings",328 children: "Settings",
277 onClick: openSettings,329 onClick: openSettings,
278 },330 },
279 actionVariant: "outline",331 secondaryActionVariant: "outline" as const,
280 data: {332 }
281 leadingIcon:333 : {}),
282 updateProviders.length === 1 ? (334 },
283 <ProviderUpdateToastIcon provider={updateProviders[0]!} />335 });
284 ) : undefined,336 if (updateExistingPrompt && activeToast?.kind === "prompt") {
285 hideCopyButton: true,337 toastId = activeToast.toastId;
286 onClose: dismissPrompt,338 toastManager.update(toastId, toastOptions);
287 ...(oneClickProviders.length > 0339 } else {
288 ? {340 toastId = toastManager.add(toastOptions);
289 secondaryActionProps: {341 }
290 children: "Settings",342 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders };
291 onClick: openSettings,
292 },
293 secondaryActionVariant: "outline" as const,
294 }
295 : {}),
296 },
297 }),
298 );
299 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId };
300 }, [343 }, [
344 canManageProviders,
301 updateProvider,345 updateProvider,
302 dismissNotificationKey,346 dismissNotificationKey,
303 dismissedNotificationKeys,347 dismissedNotificationKeys,
apps/web/src/components/ServerUpdateAction.test.tsx+87-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { act, type ReactElement } from "react";1import { act, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts";4import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts";
5import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
6import * as Schema from "effect/Schema";
6import { AsyncResult } from "effect/reactivity";7import { AsyncResult } from "effect/reactivity";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";8import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
89
...@@ -11,6 +12,8 @@ const testState = vi.hoisted(() => ({...@@ -11,6 +12,8 @@ const testState = vi.hoisted(() => ({
11 toast: vi.fn(),12 toast: vi.fn(),
12 clipboard: vi.fn(),13 clipboard: vi.fn(),
13 continueThreadsAfterServerUpdate: false,14 continueThreadsAfterServerUpdate: false,
15 session: null as AsyncResult.AsyncResult<AuthSessionState, Error> | null,
16 sessionAtom: Symbol("session"),
14}));17}));
1518
16vi.mock("~/hooks/useCopyToClipboard", () => ({19vi.mock("~/hooks/useCopyToClipboard", () => ({
...@@ -27,6 +30,13 @@ vi.mock("~/hooks/useSettings", () => ({...@@ -27,6 +30,13 @@ vi.mock("~/hooks/useSettings", () => ({
27 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,30 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,
28 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),31 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),
29}));32}));
33vi.mock("@effect/atom-react", () => ({ useAtomValue: () => testState.session }));
34vi.mock("~/rpc/atomRegistry", () => ({
35 appAtomRegistry: { get: () => testState.session },
36}));
37vi.mock("~/state/session", () => ({
38 environmentSession: { sessionStateAtom: () => testState.sessionAtom },
39}));
30vi.mock("~/state/server", () => ({40vi.mock("~/state/server", () => ({
31 serverEnvironment: { updateServer: Symbol("updateServer") },41 serverEnvironment: { updateServer: Symbol("updateServer") },
32}));42}));
...@@ -50,6 +60,8 @@ import {...@@ -50,6 +60,8 @@ import {
50 type ServerUpdateTarget,60 type ServerUpdateTarget,
51} from "./ServerUpdateAction";61} from "./ServerUpdateAction";
5262
63const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState);
64
53type ActionElement = ReactElement<{65type ActionElement = ReactElement<{
54 readonly onClick?: () => void;66 readonly onClick?: () => void;
55}>;67}>;
...@@ -68,12 +80,86 @@ async function flushPromises(): Promise<void> {...@@ -68,12 +80,86 @@ async function flushPromises(): Promise<void> {
68 await Promise.resolve();80 await Promise.resolve();
69}81}
7082
83const legacyAuth = {
84 policy: "remote-reachable",
85 bootstrapMethods: ["one-time-token"],
86 sessionMethods: ["bearer-access-token"],
87 sessionCookieName: "t3_session",
88} as const;
89
90const currentSession = {
91 authenticated: true,
92 scopes: ["environment:maintain"],
93 auth: {
94 ...legacyAuth,
95 serverUpdateScope: "environment:maintain",
96 },
97} as const satisfies AuthSessionState;
98
71describe("ServerUpdateAction", () => {99describe("ServerUpdateAction", () => {
72 beforeEach(() => {100 beforeEach(() => {
73 testState.updateServer.mockReset();101 testState.updateServer.mockReset();
74 testState.toast.mockReset();102 testState.toast.mockReset();
75 testState.clipboard.mockReset();103 testState.clipboard.mockReset();
76 testState.continueThreadsAfterServerUpdate = false;104 testState.continueThreadsAfterServerUpdate = false;
105 testState.session = AsyncResult.success(currentSession);
106 });
107
108 it.each([
109 { serverUpdateScope: undefined, scopes: ["orchestration:operate"], allowed: true },
110 { serverUpdateScope: undefined, scopes: ["orchestration:read"], allowed: false },
111 {
112 serverUpdateScope: "environment:maintain",
113 scopes: ["orchestration:operate"],
114 allowed: false,
115 },
116 {
117 serverUpdateScope: "environment:maintain",
118 scopes: ["environment:maintain"],
119 allowed: true,
120 },
121 ] as const)(
122 "uses the advertised update scope $serverUpdateScope with grant $scopes",
123 async ({ serverUpdateScope, scopes, allowed }) => {
124 testState.session = AsyncResult.success(
125 decodeSessionState({
126 ...currentSession,
127 scopes,
128 auth: {
129 ...legacyAuth,
130 ...(serverUpdateScope === undefined ? {} : { serverUpdateScope }),
131 },
132 }),
133 );
134 testState.updateServer.mockResolvedValue(
135 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
136 );
137
138 renderAction().props.onClick?.();
139 await flushPromises();
140
141 expect(testState.updateServer).toHaveBeenCalledTimes(allowed ? 1 : 0);
142 },
143 );
144
145 it("keeps a known grant usable while the session refreshes", async () => {
146 testState.session = AsyncResult.waiting(AsyncResult.success(currentSession));
147 testState.updateServer.mockResolvedValue(
148 AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }),
149 );
150
151 renderAction().props.onClick?.();
152 await flushPromises();
153
154 expect(testState.updateServer).toHaveBeenCalledOnce();
155 });
156
157 it("does not dispatch an update after maintenance access is removed", async () => {
158 const action = renderAction();
159 testState.session = AsyncResult.success({ ...currentSession, scopes: [] });
160 action.props.onClick?.();
161 await flushPromises();
162 expect(testState.updateServer).not.toHaveBeenCalled();
77 });163 });
78164
79 it.each([165 it.each([
apps/web/src/components/ServerUpdateAction.tsx+36-3
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope, type AuthSessionState } from "@t3tools/contracts";
3import type { AsyncResult } from "effect/reactivity";
4import { environmentSession } from "~/state/session";
1import type {5import type {
2 EnvironmentId,6 EnvironmentId,
3 ServerInstallation,7 ServerInstallation,
...@@ -15,6 +19,7 @@ import { requestConfirmDialog } from "~/confirmDialog";...@@ -15,6 +19,7 @@ import { requestConfirmDialog } from "~/confirmDialog";
15import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";19import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
16import { useEnvironmentSettings } from "~/hooks/useSettings";20import { useEnvironmentSettings } from "~/hooks/useSettings";
17import { serverEnvironment, updateOutdatedServer } from "~/state/server";21import { serverEnvironment, updateOutdatedServer } from "~/state/server";
22import { appAtomRegistry } from "~/rpc/atomRegistry";
18import { useAtomCommand } from "~/state/use-atom-command";23import { useAtomCommand } from "~/state/use-atom-command";
19import { manualServerUpdateCommand } from "~/versionSkew";24import { manualServerUpdateCommand } from "~/versionSkew";
20import { Button } from "./ui/button";25import { Button } from "./ui/button";
...@@ -60,7 +65,11 @@ function useServerUpdate() {...@@ -60,7 +65,11 @@ function useServerUpdate() {
60 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });65 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
61 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {66 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
62 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;67 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
63 if (pendingUpdateEnvironmentIds.has(environmentId)) return;68 if (
69 !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) ||
70 pendingUpdateEnvironmentIds.has(environmentId)
71 )
72 return;
64 pendingUpdateEnvironmentIds.add(environmentId);73 pendingUpdateEnvironmentIds.add(environmentId);
65 try {74 try {
66 const result = await updateServer({75 const result = await updateServer({
...@@ -151,6 +160,17 @@ export function ServerUpdatesAction({...@@ -151,6 +160,17 @@ export function ServerUpdatesAction({
151 );160 );
152}161}
153162
163function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {
164 if (result._tag !== "Success" || !result.value.authenticated) return false;
165 const session = result.value;
166 // Only self-update bridges the old authorization protocol. Upgraded servers
167 // advertise the new scope even when this client's grant predates it.
168 return (
169 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
170 true
171 );
172}
173
154/**174/**
155 * One-row status for an in-flight server update: "Downloading…" then175 * One-row status for an in-flight server update: "Downloading…" then
156 * "Restarting…". The update is a wait, not a warning: a single pulsing dot176 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
...@@ -204,6 +224,8 @@ export function ServerUpdateAction({...@@ -204,6 +224,8 @@ export function ServerUpdateAction({
204 appearance = "button",224 appearance = "button",
205}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {225}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
206 const isDesktopAppUpdate = selfUpdate === "desktop-managed";226 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
227 const sessionStateAtom = environmentSession.sessionStateAtom(environmentId);
228 const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom));
207 const continueThreadsAfterServerUpdate = useEnvironmentSettings(229 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
208 environmentId,230 environmentId,
209 (settings) => settings.continueThreadsAfterServerUpdate,231 (settings) => settings.continueThreadsAfterServerUpdate,
...@@ -232,7 +254,10 @@ export function ServerUpdateAction({...@@ -232,7 +254,10 @@ export function ServerUpdateAction({
232 });254 });
233255
234 const handleUpdate = async () => {256 const handleUpdate = async () => {
235 if (pendingUpdateEnvironmentIds.has(environmentId)) {257 if (
258 !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) ||
259 pendingUpdateEnvironmentIds.has(environmentId)
260 ) {
236 return;261 return;
237 }262 }
238 if (isDesktopAppUpdate) {263 if (isDesktopAppUpdate) {
...@@ -247,6 +272,7 @@ export function ServerUpdateAction({...@@ -247,6 +272,7 @@ export function ServerUpdateAction({
247 return;272 return;
248 }273 }
249 }274 }
275 if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return;
250 await update({276 await update({
251 environmentId,277 environmentId,
252 serverLabel,278 serverLabel,
...@@ -289,6 +315,7 @@ export function ServerUpdateAction({...@@ -289,6 +315,7 @@ export function ServerUpdateAction({
289 variant="ghost-muted"315 variant="ghost-muted"
290 className={className}316 className={className}
291 aria-label={`${actionLabel} for ${serverLabel}`}317 aria-label={`${actionLabel} for ${serverLabel}`}
318 disabled={manualCommand === null && !canUpdate}
292 onClick={onClick}319 onClick={onClick}
293 />320 />
294 }321 }
...@@ -301,7 +328,13 @@ export function ServerUpdateAction({...@@ -301,7 +328,13 @@ export function ServerUpdateAction({
301 }328 }
302329
303 return (330 return (
304 <Button size={size} variant={variant} className={className} onClick={onClick}>331 <Button
332 size={size}
333 variant={variant}
334 className={className}
335 disabled={manualCommand === null && !canUpdate}
336 onClick={onClick}
337 >
305 {actionLabel}338 {actionLabel}
306 </Button>339 </Button>
307 );340 );
apps/web/src/components/chat/ChatComposer.tsx+26-1
...@@ -38,6 +38,7 @@ import type {...@@ -38,6 +38,7 @@ import type {
38 SnapShotSource,38 SnapShotSource,
39} from "@t3tools/contracts";39} from "@t3tools/contracts";
40import {40import {
41 AuthOrchestrationOperateScope,
41 ProviderDriverKind,42 ProviderDriverKind,
42 ProviderInstanceId,43 ProviderInstanceId,
43 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,44 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
...@@ -1130,6 +1131,7 @@ import { useDelayedStatus } from "../../hooks/useDelayedStatus";...@@ -1130,6 +1131,7 @@ import { useDelayedStatus } from "../../hooks/useDelayedStatus";
1130import { useMediaQuery } from "../../hooks/useMediaQuery";1131import { useMediaQuery } from "../../hooks/useMediaQuery";
1131import { usePanelAnimationSettings } from "../../panelAnimations";1132import { usePanelAnimationSettings } from "../../panelAnimations";
1132import { useAtomCommand } from "../../state/use-atom-command";1133import { useAtomCommand } from "../../state/use-atom-command";
1134import { readEnvironmentScope } from "../../state/session";
1133import { serverEnvironment } from "../../state/server";1135import { serverEnvironment } from "../../state/server";
1134import type { ReviewCommentContext } from "../../reviewCommentContext";1136import type { ReviewCommentContext } from "../../reviewCommentContext";
11351137
...@@ -1348,6 +1350,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -1348,6 +1350,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
13481350
1349const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {1351const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
1350 compact: boolean;1352 compact: boolean;
1353 canOperateThread: boolean;
1351 activeContextWindow: ContextWindowSnapshot | null;1354 activeContextWindow: ContextWindowSnapshot | null;
1352 reserveContextWindowMeter: boolean;1355 reserveContextWindowMeter: boolean;
1353 activeThreadModelDisplayName: string | null;1356 activeThreadModelDisplayName: string | null;
...@@ -1397,6 +1400,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -1397,6 +1400,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
1397 ) : null}1400 ) : null}
1398 <ComposerPrimaryActions1401 <ComposerPrimaryActions
1399 compact={props.compact}1402 compact={props.compact}
1403 canOperateThread={props.canOperateThread}
1400 pendingAction={props.pendingAction}1404 pendingAction={props.pendingAction}
1401 isRunning={props.isRunning}1405 isRunning={props.isRunning}
1402 canInterrupt={props.canInterrupt}1406 canInterrupt={props.canInterrupt}
...@@ -1499,6 +1503,7 @@ export interface ChatComposerHandle {...@@ -1499,6 +1503,7 @@ export interface ChatComposerHandle {
1499export interface ChatComposerProps {1503export interface ChatComposerProps {
1500 composerDraftTarget: ScopedThreadRef | DraftId;1504 composerDraftTarget: ScopedThreadRef | DraftId;
1501 environmentId: EnvironmentId;1505 environmentId: EnvironmentId;
1506 canOperateThread: boolean;
1502 attachmentUploadsCapabilityKnown: boolean;1507 attachmentUploadsCapabilityKnown: boolean;
1503 supportsAttachmentUploads: boolean;1508 supportsAttachmentUploads: boolean;
1504 supportsQuestionAttachments: boolean;1509 supportsQuestionAttachments: boolean;
...@@ -1685,6 +1690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1685,6 +1690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1685 const {1690 const {
1686 composerDraftTarget,1691 composerDraftTarget,
1687 environmentId,1692 environmentId,
1693 canOperateThread,
1688 attachmentUploadsCapabilityKnown,1694 attachmentUploadsCapabilityKnown,
1689 supportsAttachmentUploads,1695 supportsAttachmentUploads,
1690 supportsQuestionAttachments,1696 supportsQuestionAttachments,
...@@ -2001,6 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2001,6 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2001 }2007 }
2002 return;2008 return;
2003 }2009 }
2010 if (!canOperateThread) return;
2004 const invalidFiles =2011 const invalidFiles =
2005 maxFileAttachmentBytes === null2012 maxFileAttachmentBytes === null
2006 ? composerFiles2013 ? composerFiles
...@@ -2027,6 +2034,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2027,6 +2034,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2027 }, [2034 }, [
2028 attachmentUploadsCapabilityKnown,2035 attachmentUploadsCapabilityKnown,
2029 attachmentDraftTarget,2036 attachmentDraftTarget,
2037 canOperateThread,
2030 composerFiles,2038 composerFiles,
2031 composerImages,2039 composerImages,
2032 environmentId,2040 environmentId,
...@@ -4147,7 +4155,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4147,7 +4155,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4147 dispatchMode?: ComposerDispatchMode,4155 dispatchMode?: ComposerDispatchMode,
4148 submissionIntent?: ComposerSubmissionIntent,4156 submissionIntent?: ComposerSubmissionIntent,
4149 ) => {4157 ) => {
4150 if (noProviderAvailable || isSendDisabled) {4158 if (
4159 noProviderAvailable ||
4160 isSendDisabled ||
4161 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
4162 ) {
4151 event?.preventDefault();4163 event?.preventDefault();
4152 return;4164 return;
4153 }4165 }
...@@ -4210,6 +4222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4210,6 +4222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4210 activePendingProgress,4222 activePendingProgress,
4211 attachmentTargetKey,4223 attachmentTargetKey,
4212 blurMobileComposerAfterSend,4224 blurMobileComposerAfterSend,
4225 environmentId,
4213 isSendDisabled,4226 isSendDisabled,
4214 noProviderAvailable,4227 noProviderAvailable,
4215 onSend,4228 onSend,
...@@ -4245,6 +4258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4245,6 +4258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4245 }, [phase, settings.followUpBehavior, submitComposer]);4258 }, [phase, settings.followUpBehavior, submitComposer]);
4246 const compactThreadContext = useCallback(() => {4259 const compactThreadContext = useCallback(() => {
4247 if (4260 if (
4261 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
4248 compactDisabled ||4262 compactDisabled ||
4249 noProviderAvailable ||4263 noProviderAvailable ||
4250 activePendingApproval !== null ||4264 activePendingApproval !== null ||
...@@ -4261,6 +4275,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4261,6 +4275,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4261 activePendingApproval,4275 activePendingApproval,
4262 activeThreadId,4276 activeThreadId,
4263 compactDisabled,4277 compactDisabled,
4278 composerDraftTarget,
4279 environmentId,
4264 isConnecting,4280 isConnecting,
4265 isSendBusy,4281 isSendBusy,
4266 noProviderAvailable,4282 noProviderAvailable,
...@@ -6650,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6650,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6650 <ComposerBanner.Actions>6666 <ComposerBanner.Actions>
6651 <ComposerPendingApprovalActions6667 <ComposerPendingApprovalActions
6652 requestId={activePendingApproval.requestId}6668 requestId={activePendingApproval.requestId}
6669 disabled={!canOperateThread}
6653 isResponding={respondingRequestIds.includes(6670 isResponding={respondingRequestIds.includes(
6654 activePendingApproval.requestId,6671 activePendingApproval.requestId,
6655 )}6672 )}
...@@ -6662,6 +6679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6662,6 +6679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6662 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (6679 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
6663 <ComposerPendingUserInputPanel6680 <ComposerPendingUserInputPanel
6664 pendingUserInputs={pendingUserInputs}6681 pendingUserInputs={pendingUserInputs}
6682 disabled={!canOperateThread}
6665 respondingRequestIds={6683 respondingRequestIds={
6666 activePendingIsResponding && activePendingUserInput6684 activePendingIsResponding && activePendingUserInput
6667 ? [...respondingRequestIds, activePendingUserInput.requestId]6685 ? [...respondingRequestIds, activePendingUserInput.requestId]
...@@ -6682,6 +6700,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6682,6 +6700,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6682 <div data-chat-composer-collapsed-controls="true">6700 <div data-chat-composer-collapsed-controls="true">
6683 <ComposerPendingUserInputPanel6701 <ComposerPendingUserInputPanel
6684 pendingUserInputs={pendingUserInputs}6702 pendingUserInputs={pendingUserInputs}
6703 disabled={!canOperateThread}
6685 respondingRequestIds={6704 respondingRequestIds={
6686 activePendingIsResponding && activePendingUserInput6705 activePendingIsResponding && activePendingUserInput
6687 ? [...respondingRequestIds, activePendingUserInput.requestId]6706 ? [...respondingRequestIds, activePendingUserInput.requestId]
...@@ -6723,6 +6742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6723,6 +6742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6723 {activePendingProgress?.activeQuestion?.multiSelect ? (6742 {activePendingProgress?.activeQuestion?.multiSelect ? (
6724 <ComposerPrimaryActions6743 <ComposerPrimaryActions
6725 compact6744 compact
6745 canOperateThread={canOperateThread}
6726 pendingAction={pendingPrimaryAction}6746 pendingAction={pendingPrimaryAction}
6727 isRunning={false}6747 isRunning={false}
6728 canInterrupt={false}6748 canInterrupt={false}
...@@ -7079,6 +7099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7079,6 +7099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7079 draftTarget: attachmentDraftTarget,7099 draftTarget: attachmentDraftTarget,
7080 })7100 })
7081 }7101 }
7102 disabled={!canOperateThread}
7082 aria-label={`Retry upload for ${image.name}`}7103 aria-label={`Retry upload for ${image.name}`}
7083 />7104 />
7084 }7105 }
...@@ -7178,6 +7199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7178,6 +7199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7178 draftTarget: attachmentDraftTarget,7199 draftTarget: attachmentDraftTarget,
7179 })7200 })
7180 }7201 }
7202 disabled={!canOperateThread}
7181 aria-label={`Retry upload for ${file.name}`}7203 aria-label={`Retry upload for ${file.name}`}
7182 />7204 />
7183 }7205 }
...@@ -7256,6 +7278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7256,6 +7278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7256 draftTarget: attachmentDraftTarget,7278 draftTarget: attachmentDraftTarget,
7257 })7279 })
7258 }7280 }
7281 disabled={!canOperateThread}
7259 aria-label={`Retry upload for ${file.name}`}7282 aria-label={`Retry upload for ${file.name}`}
7260 />7283 />
7261 }7284 }
...@@ -7408,6 +7431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7408,6 +7431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7408 >7431 >
7409 <ComposerPrimaryActions7432 <ComposerPrimaryActions
7410 compact7433 compact
7434 canOperateThread={canOperateThread}
7411 pendingAction={pendingPrimaryAction}7435 pendingAction={pendingPrimaryAction}
7412 isRunning={false}7436 isRunning={false}
7413 canInterrupt={false}7437 canInterrupt={false}
...@@ -7513,6 +7537,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7513,6 +7537,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7513 ) : null}7537 ) : null}
7514 <ComposerFooterPrimaryActions7538 <ComposerFooterPrimaryActions
7515 compact={isComposerResting || isComposerPrimaryActionsCompact}7539 compact={isComposerResting || isComposerPrimaryActionsCompact}
7540 canOperateThread={canOperateThread}
7516 activeContextWindow={7541 activeContextWindow={
7517 settings.contextWindowMeterEnabled ? activeContextWindow : null7542 settings.contextWindowMeterEnabled ? activeContextWindow : null
7518 }7543 }
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+14-5
...@@ -14,6 +14,7 @@ interface ComposerPendingApprovalActionsProps {...@@ -14,6 +14,7 @@ interface ComposerPendingApprovalActionsProps {
14 requestId: RuntimeRequestId;14 requestId: RuntimeRequestId;
15 isResponding: boolean;15 isResponding: boolean;
16 canRespond: boolean;16 canRespond: boolean;
17 disabled?: boolean;
17 options?: ReadonlyArray<ProviderApprovalOption> | undefined;18 options?: ReadonlyArray<ProviderApprovalOption> | undefined;
18 onRespondToApproval: (19 onRespondToApproval: (
19 requestId: RuntimeRequestId,20 requestId: RuntimeRequestId,
...@@ -32,6 +33,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -32,6 +33,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
32 requestId,33 requestId,
33 isResponding,34 isResponding,
34 canRespond,35 canRespond,
36 disabled = false,
35 options = DEFAULT_APPROVAL_OPTIONS,37 options = DEFAULT_APPROVAL_OPTIONS,
36 onRespondToApproval,38 onRespondToApproval,
37}: ComposerPendingApprovalActionsProps) {39}: ComposerPendingApprovalActionsProps) {
...@@ -50,9 +52,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -50,9 +52,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
50 key={option.decision}52 key={option.decision}
51 size="xs"53 size="xs"
52 variant={option.decision === "accept" ? "default" : "outline"}54 variant={option.decision === "accept" ? "default" : "outline"}
53 disabled={isResponding || !canRespond}55 disabled={disabled || isResponding || !canRespond}
54 aria-description={option.warning}56 aria-description={option.warning}
55 onClick={() => void onRespondToApproval(requestId, option.decision)}57 onClick={() => {
58 if (!disabled && !isResponding) {
59 void onRespondToApproval(requestId, option.decision);
60 }
61 }}
56 >62 >
57 {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}63 {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}
58 <span className="max-w-40 truncate">{option.label}</span>64 <span className="max-w-40 truncate">{option.label}</span>
...@@ -70,7 +76,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -70,7 +76,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
70 {moreOptions.length > 0 ? (76 {moreOptions.length > 0 ? (
71 <Menu>77 <Menu>
72 <MenuTrigger78 <MenuTrigger
73 disabled={isResponding}79 disabled={disabled || isResponding}
74 render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}80 render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
75 >81 >
76 <EllipsisIcon />82 <EllipsisIcon />
...@@ -80,9 +86,12 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -80,9 +86,12 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
80 const item = (86 const item = (
81 <MenuItem87 <MenuItem
82 key={option.decision}88 key={option.decision}
83 disabled={isResponding}89 disabled={disabled || isResponding}
84 aria-description={option.warning}90 aria-description={option.warning}
85 onClick={() => void onRespondToApproval(requestId, option.decision)}91 onClick={() => {
92 if (!disabled && !isResponding)
93 void onRespondToApproval(requestId, option.decision);
94 }}
86 variant="ghost"95 variant="ghost"
87 className="mb-1 last:mb-0"96 className="mb-1 last:mb-0"
88 >97 >
apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx created+93
...@@ -0,0 +1,93 @@
1import { RuntimeRequestId } from "@t3tools/contracts";
2import { act, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { expect, it, vi } from "vite-plus/test";
5
6vi.mock("../ui/collapsible", () => {
7 const Children = ({ children }: { children: ReactNode }) => <>{children}</>;
8 return {
9 Collapsible: Children,
10 CollapsiblePanel: Children,
11 CollapsibleTrigger: Children,
12 };
13});
14
15import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
16
17it("cancels an answer's pending auto-submit on revocation and resumes after a new grant", async () => {
18 vi.useFakeTimers();
19 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
20 vi.stubGlobal("window", {
21 setTimeout: globalThis.setTimeout,
22 clearTimeout: globalThis.clearTimeout,
23 });
24 vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
25 const onToggleOption = vi.fn();
26 const onAdvance = vi.fn();
27 const onDismiss = vi.fn();
28 const prompt = {
29 requestId: RuntimeRequestId.make("scoped-answer"),
30 createdAt: "2026-09-05T00:00:00.000Z",
31 dismissible: false,
32 responseCapability: "live" as const,
33 questions: [
34 {
35 id: "approach",
36 header: "Approach",
37 question: "Which approach?",
38 options: [{ label: "Incremental", description: "One module at a time" }],
39 multiSelect: false,
40 },
41 ],
42 };
43 const panel = (disabled: boolean) => (
44 <ComposerPendingUserInputPanel
45 pendingUserInputs={[prompt]}
46 disabled={disabled}
47 respondingRequestIds={[]}
48 answers={{}}
49 questionIndex={0}
50 onToggleOption={onToggleOption}
51 onAdvance={onAdvance}
52 onDismiss={onDismiss}
53 />
54 );
55 let renderer: ReactTestRenderer | undefined;
56 const chooseOption = () => {
57 const option = renderer!.root
58 .findAllByType("button")
59 .find((button) =>
60 button.findAllByType("span").some((span) => span.children.includes("Incremental")),
61 );
62 expect(option).toBeDefined();
63 option!.props.onClick();
64 };
65 try {
66 await act(async () => {
67 renderer = create(panel(false));
68 });
69 await act(async () => chooseOption());
70 expect(onToggleOption).toHaveBeenCalledWith("approach", "Incremental");
71 expect(onAdvance).not.toHaveBeenCalled();
72
73 await act(async () => renderer!.update(panel(true)));
74 await act(async () => {
75 chooseOption();
76 vi.runAllTimers();
77 });
78 expect(onToggleOption).toHaveBeenCalledTimes(1);
79 expect(onAdvance).not.toHaveBeenCalled();
80
81 await act(async () => renderer!.update(panel(false)));
82 await act(async () => {
83 chooseOption();
84 vi.runAllTimers();
85 });
86 expect(onToggleOption).toHaveBeenCalledTimes(2);
87 expect(onAdvance).toHaveBeenCalledTimes(1);
88 } finally {
89 await act(async () => renderer?.unmount());
90 vi.unstubAllGlobals();
91 vi.useRealTimers();
92 }
93});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+18-5
...@@ -12,6 +12,7 @@ import { ComposerBanner } from "./ComposerBanner";...@@ -12,6 +12,7 @@ import { ComposerBanner } from "./ComposerBanner";
1212
13interface PendingUserInputPanelProps {13interface PendingUserInputPanelProps {
14 pendingUserInputs: PendingUserInput[];14 pendingUserInputs: PendingUserInput[];
15 disabled?: boolean;
15 respondingRequestIds: RuntimeRequestId[];16 respondingRequestIds: RuntimeRequestId[];
16 answers: Record<string, PendingUserInputDraftAnswer>;17 answers: Record<string, PendingUserInputDraftAnswer>;
17 questionIndex: number;18 questionIndex: number;
...@@ -22,6 +23,7 @@ interface PendingUserInputPanelProps {...@@ -22,6 +23,7 @@ interface PendingUserInputPanelProps {
2223
23export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({24export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
24 pendingUserInputs,25 pendingUserInputs,
26 disabled = false,
25 respondingRequestIds,27 respondingRequestIds,
26 answers,28 answers,
27 questionIndex,29 questionIndex,
...@@ -37,6 +39,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -37,6 +39,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
37 <ComposerPendingUserInputCard39 <ComposerPendingUserInputCard
38 key={activePrompt.requestId}40 key={activePrompt.requestId}
39 prompt={activePrompt}41 prompt={activePrompt}
42 disabled={disabled}
40 isResponding={respondingRequestIds.includes(activePrompt.requestId)}43 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
41 answers={answers}44 answers={answers}
42 questionIndex={questionIndex}45 questionIndex={questionIndex}
...@@ -49,6 +52,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn...@@ -49,6 +52,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
4952
50const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({53const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
51 prompt,54 prompt,
55 disabled,
52 isResponding,56 isResponding,
53 answers,57 answers,
54 questionIndex,58 questionIndex,
...@@ -57,6 +61,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -57,6 +61,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
57 onDismiss,61 onDismiss,
58}: {62}: {
59 prompt: PendingUserInput;63 prompt: PendingUserInput;
64 disabled: boolean;
60 isResponding: boolean;65 isResponding: boolean;
61 answers: Record<string, PendingUserInputDraftAnswer>;66 answers: Record<string, PendingUserInputDraftAnswer>;
62 questionIndex: number;67 questionIndex: number;
...@@ -66,7 +71,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -66,7 +71,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
66}) {71}) {
67 // Message-mode requests remain answerable after their provider turn ends.72 // Message-mode requests remain answerable after their provider turn ends.
68 const canRespond = prompt.responseCapability !== "not_resumable";73 const canRespond = prompt.responseCapability !== "not_resumable";
69 const responseDisabled = isResponding || !canRespond;74 const responseDisabled = disabled || isResponding || !canRespond;
70 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);75 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
71 const activeQuestion = progress.activeQuestion;76 const activeQuestion = progress.activeQuestion;
72 const autoAdvanceTimerRef = useRef<number | null>(null);77 const autoAdvanceTimerRef = useRef<number | null>(null);
...@@ -88,6 +93,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -88,6 +93,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
88 onAdvanceRef.current = onAdvance;93 onAdvanceRef.current = onAdvance;
89 }, [onAdvance]);94 }, [onAdvance]);
9095
96 useEffect(() => {
97 if (disabled && autoAdvanceTimerRef.current !== null) {
98 window.clearTimeout(autoAdvanceTimerRef.current);
99 autoAdvanceTimerRef.current = null;
100 }
101 }, [disabled]);
102
91 useEffect(() => {103 useEffect(() => {
92 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {104 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {
93 return;105 return;
...@@ -120,6 +132,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -120,6 +132,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
120132
121 const handleOptionSelection = useCallback(133 const handleOptionSelection = useCallback(
122 (questionId: string, optionValue: string) => {134 (questionId: string, optionValue: string) => {
135 if (disabled || isResponding) return;
123 if (activeQuestion?.multiSelect) {136 if (activeQuestion?.multiSelect) {
124 onToggleOption(questionId, optionValue);137 onToggleOption(questionId, optionValue);
125 return;138 return;
...@@ -134,7 +147,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -134,7 +147,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
134 onAdvanceRef.current();147 onAdvanceRef.current();
135 }, 200);148 }, 200);
136 },149 },
137 [activeQuestion, onToggleOption],150 [activeQuestion, disabled, isResponding, onToggleOption],
138 );151 );
139152
140 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is153 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
...@@ -252,8 +265,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -252,8 +265,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
252 isSelected265 isSelected
253 ? "bg-muted/55 text-foreground"266 ? "bg-muted/55 text-foreground"
254 : "bg-transparent text-foreground/85 hover:bg-muted/30",267 : "bg-transparent text-foreground/85 hover:bg-muted/30",
255 isResponding && "opacity-50 cursor-not-allowed",268 (disabled || isResponding) && "opacity-50 cursor-not-allowed",
256 !isResponding && "cursor-pointer",269 !disabled && !isResponding && "cursor-pointer",
257 );270 );
258 const content = (271 const content = (
259 <>272 <>
...@@ -280,7 +293,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -280,7 +293,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
280 <button293 <button
281 key={`${activeQuestion.id}:${optionValue}`}294 key={`${activeQuestion.id}:${optionValue}`}
282 type="button"295 type="button"
283 disabled={isResponding}296 disabled={disabled || isResponding}
284 onClick={() => {297 onClick={() => {
285 handleOptionSelection(activeQuestion.id, optionValue);298 handleOptionSelection(activeQuestion.id, optionValue);
286 }}299 }}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+2
...@@ -21,6 +21,7 @@ function renderPendingActions(isRunning: boolean) {...@@ -21,6 +21,7 @@ function renderPendingActions(isRunning: boolean) {
21 return renderToStaticMarkup(21 return renderToStaticMarkup(
22 createElement(ComposerPrimaryActions, {22 createElement(ComposerPrimaryActions, {
23 compact: true,23 compact: true,
24 canOperateThread: true,
24 pendingAction: {25 pendingAction: {
25 questionIndex: 0,26 questionIndex: 0,
26 isLastQuestion: true,27 isLastQuestion: true,
...@@ -49,6 +50,7 @@ function renderSendButton(sendDisabledReason: string | null = null) {...@@ -49,6 +50,7 @@ function renderSendButton(sendDisabledReason: string | null = null) {
49 return renderToStaticMarkup(50 return renderToStaticMarkup(
50 createElement(ComposerPrimaryActions, {51 createElement(ComposerPrimaryActions, {
51 compact: true,52 compact: true,
53 canOperateThread: true,
52 pendingAction: null,54 pendingAction: null,
53 isRunning: false,55 isRunning: false,
54 canInterrupt: false,56 canInterrupt: false,
apps/web/src/components/chat/ComposerPrimaryActions.tsx+11-3
...@@ -26,6 +26,7 @@ interface PendingActionState {...@@ -26,6 +26,7 @@ interface PendingActionState {
2626
27interface ComposerPrimaryActionsProps {27interface ComposerPrimaryActionsProps {
28 compact: boolean;28 compact: boolean;
29 canOperateThread: boolean;
29 pendingAction: PendingActionState | null;30 pendingAction: PendingActionState | null;
30 /** The turn is running: sending steers or queues instead of starting a turn. */31 /** The turn is running: sending steers or queues instead of starting a turn. */
31 isRunning: boolean;32 isRunning: boolean;
...@@ -80,6 +81,7 @@ const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {...@@ -80,6 +81,7 @@ const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
8081
81export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({82export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
82 compact,83 compact,
84 canOperateThread,
83 pendingAction,85 pendingAction,
84 isRunning,86 isRunning,
85 canInterrupt,87 canInterrupt,
...@@ -115,7 +117,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -115,7 +117,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
115 alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey,117 alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey,
116 }) === "queue";118 }) === "queue";
117 const alternateAction = alternateComposerDispatchAction(followUpBehavior);119 const alternateAction = alternateComposerDispatchAction(followUpBehavior);
118 const isSendDisabled = sendDisabledReason !== null;120 const isSendDisabled = !canOperateThread || sendDisabledReason !== null;
119 const stageBackdropVariant = useSidebarStageBackdropVariant(121 const stageBackdropVariant = useSidebarStageBackdropVariant(
120 environmentIdentificationMode === "artwork",122 environmentIdentificationMode === "artwork",
121 );123 );
...@@ -131,7 +133,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -131,7 +133,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
131 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",133 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",
132 )}134 )}
133 {...pointerFocusProps}135 {...pointerFocusProps}
134 onClick={onInterrupt}136 disabled={!canOperateThread}
137 onClick={() => {
138 if (canOperateThread) onInterrupt();
139 }}
135 aria-label="Stop generation"140 aria-label="Stop generation"
136 />141 />
137 }142 }
...@@ -177,6 +182,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -177,6 +182,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
177 className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")}182 className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")}
178 {...pointerFocusProps}183 {...pointerFocusProps}
179 disabled={184 disabled={
185 !canOperateThread ||
180 isEnvironmentUnavailable ||186 isEnvironmentUnavailable ||
181 pendingAction.isResponding ||187 pendingAction.isResponding ||
182 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)188 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
...@@ -237,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({...@@ -237,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
237 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>243 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
238 <MenuItem244 <MenuItem
239 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}245 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
240 onClick={() => void onImplementPlanInNewThread()}246 onClick={() => {
247 if (canOperateThread) onImplementPlanInNewThread();
248 }}
241 >249 >
242 Implement in a new thread250 Implement in a new thread
243 </MenuItem>251 </MenuItem>
apps/web/src/components/chat/OpenInPicker.test.tsx created+226
...@@ -0,0 +1,226 @@
1import { AuthOrchestrationOperateScope, EnvironmentId, type EditorId } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import { AsyncResult } from "effect/reactivity";
4import { cloneElement, useState, type ReactElement, type ReactNode } from "react";
5import { act, create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import type { RemoteOpenState } from "../../remoteOpen";
9
10const state = vi.hoisted(() => ({
11 allowed: new Set<string>(),
12 remote: { mode: "local-exec" } as RemoteOpenState,
13 run: vi.fn(),
14 openUrl: vi.fn(),
15 setPreferred: vi.fn(),
16 markHintSeen: vi.fn(),
17 keydown: null as ((event: KeyboardEvent) => void) | null,
18 listeners: new Set<() => void>(),
19}));
20
21vi.mock("../../state/session", async () => {
22 const { useSyncExternalStore } = await import("react");
23 const readEnvironmentScope = (environmentId: string, scope: string) =>
24 scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId);
25 return {
26 readEnvironmentScope,
27 useEnvironmentScope: (environmentId: string, scope: string) =>
28 useSyncExternalStore(
29 (listener) => {
30 state.listeners.add(listener);
31 return () => state.listeners.delete(listener);
32 },
33 () => readEnvironmentScope(environmentId, scope),
34 ),
35 };
36});
37vi.mock("../../state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
38vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
39vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Test host" }) }));
40vi.mock("../../editorPreferences", () => ({
41 usePreferredEditor: (available: readonly EditorId[]) => [
42 available[0] ?? null,
43 state.setPreferred,
44 ],
45}));
46vi.mock("../../remoteOpen", () => ({
47 useRemoteOpenState: () => state.remote,
48 useRemoteCapableEditors: () => editors,
49 useRemoteOpenHint: () => [false, state.markHintSeen],
50 openRemoteEditorUrl: state.openUrl,
51}));
52vi.mock("../../keybindings", () => ({
53 isOpenFavoriteEditorShortcut: () => true,
54 shortcutLabelForCommand: () => "Ctrl+O",
55}));
56vi.mock("../ui/button", () => ({ Button: "button" }));
57vi.mock("../ui/group", () => ({ Group: "div", GroupSeparator: "span" }));
58vi.mock("../ui/menu", () => ({
59 Menu: ({
60 open,
61 onOpenChange,
62 children,
63 }: {
64 open?: boolean;
65 onOpenChange?: (open: boolean) => void;
66 children: ReactNode;
67 }) => {
68 const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
69 const visible = open ?? uncontrolledOpen;
70 return (
71 <div
72 data-menu-open={visible}
73 onDoubleClick={() => {
74 setUncontrolledOpen(!visible);
75 onOpenChange?.(!visible);
76 }}
77 >
78 {children}
79 </div>
80 );
81 },
82 MenuTrigger: ({
83 render,
84 children,
85 disabled,
86 }: {
87 render: ReactElement<{ disabled?: boolean }>;
88 children: ReactNode;
89 disabled?: boolean;
90 }) => cloneElement(render, disabled === undefined ? {} : { disabled }, children),
91 MenuPopup: "section",
92 MenuItem: "button",
93 MenuItemLabel: "span",
94 MenuShortcut: "span",
95}));
96
97import { OpenInPicker } from "./OpenInPicker";
98
99const primary = EnvironmentId.make("primary");
100const selected = EnvironmentId.make("selected");
101const editors: readonly EditorId[] = ["vscode"];
102let renderer: ReactTestRenderer | undefined;
103
104beforeEach(() => {
105 state.allowed.clear();
106 state.remote = { mode: "local-exec" };
107 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
108 state.openUrl.mockReset().mockResolvedValue(true);
109 state.setPreferred.mockReset();
110 state.markHintSeen.mockReset();
111 state.keydown = null;
112 state.listeners.clear();
113 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114 vi.stubGlobal("navigator", { platform: "Linux" });
115 vi.stubGlobal("window", {
116 addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
117 state.keydown = callback;
118 },
119 removeEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => {
120 if (state.keydown === callback) state.keydown = null;
121 },
122 });
123});
124
125afterEach(async () => {
126 await act(async () => renderer?.unmount());
127 renderer = undefined;
128 vi.unstubAllGlobals();
129});
130
131async function renderPicker() {
132 await act(async () => {
133 for (const listener of state.listeners) listener();
134 const node = (
135 <OpenInPicker
136 environmentId={selected}
137 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
138 availableEditors={editors}
139 openInCwd="/work/project"
140 compact
141 />
142 );
143 if (renderer) renderer.update(node);
144 else renderer = create(node);
145 });
146}
147
148function primaryButton() {
149 return renderer!.root.findByProps({ "aria-label": "Open file in preferred editor" });
150}
151
152function keyboardEvent() {
153 return { preventDefault: vi.fn() } as unknown as KeyboardEvent;
154}
155
156describe("host editor access", () => {
157 it("uses the selected grant for both the button and a retained shortcut", async () => {
158 state.allowed.add(selected);
159 await renderPicker();
160 const open = primaryButton().props.onClick;
161 const keydown = state.keydown!;
162 state.allowed.delete(selected);
163 state.allowed.add(primary);
164 const event = keyboardEvent();
165 await act(async () => {
166 open();
167 keydown(event);
168 });
169 expect(state.run).not.toHaveBeenCalled();
170 expect(state.setPreferred).not.toHaveBeenCalled();
171 expect(event.preventDefault).not.toHaveBeenCalled();
172
173 await renderPicker();
174 expect(primaryButton().props.disabled).toBe(true);
175 expect(renderer!.root.findByProps({ "aria-label": "Choose editor" }).props.disabled).toBe(true);
176 expect(state.keydown).toBeNull();
177 });
178
179 it("closes an open picker on revocation and launches only after a fresh grant", async () => {
180 state.allowed.add(selected);
181 await renderPicker();
182 await act(async () => {
183 renderer!.root.findByProps({ "data-menu-open": false }).props.onDoubleClick();
184 });
185 expect(renderer!.root.findByProps({ "data-menu-open": true })).toBeDefined();
186 state.allowed.delete(selected);
187 await renderPicker();
188 expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined();
189 state.allowed.add(selected);
190 await renderPicker();
191 expect(renderer!.root.findByProps({ "data-menu-open": false })).toBeDefined();
192 await act(async () => primaryButton().props.onClick());
193 expect(state.run).toHaveBeenCalledExactlyOnceWith({
194 environmentId: selected,
195 input: { cwd: "/work/project", editor: "vscode" },
196 });
197 });
198});
199
200describe("client editor links", () => {
201 it("opens an SSH editor URL without host operate permission", async () => {
202 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } };
203 await renderPicker();
204 expect(primaryButton().props.disabled).toBe(false);
205 await act(async () => primaryButton().props.onClick());
206 expect(state.openUrl).toHaveBeenCalledExactlyOnceWith(
207 "vscode://vscode-remote/ssh-remote+test-host/work/project",
208 );
209 expect(state.run).not.toHaveBeenCalled();
210 expect(state.markHintSeen).toHaveBeenCalledOnce();
211 expect(state.setPreferred).toHaveBeenCalledExactlyOnceWith("vscode");
212 });
213
214 it("does not change preferences when the client rejects the SSH URL", async () => {
215 state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "test-host" } };
216 state.openUrl.mockResolvedValue(false);
217 await renderPicker();
218 const event = keyboardEvent();
219 await act(async () => state.keydown!(event));
220 expect(state.openUrl).toHaveBeenCalledOnce();
221 expect(event.preventDefault).toHaveBeenCalledOnce();
222 expect(state.run).not.toHaveBeenCalled();
223 expect(state.setPreferred).not.toHaveBeenCalled();
224 expect(state.markHintSeen).not.toHaveBeenCalled();
225 });
226});
apps/web/src/components/chat/OpenInPicker.tsx+30-6
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { ThreadDetailsControl } from "./ThreadDetailsControl";1import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import {2import {
3 AuthOrchestrationOperateScope,
3 buildRemoteOpenUrl,4 buildRemoteOpenUrl,
4 EditorId,5 EditorId,
5 type EnvironmentId,6 type EnvironmentId,
...@@ -66,6 +67,8 @@ import {...@@ -66,6 +67,8 @@ import {
66 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,67 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
67 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,68 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
68} from "./threadDetailsPanelStyles";69} from "./threadDetailsPanelStyles";
70import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
71import { useComposerMenuState } from "./useComposerMenuState";
6972
70type OpenInOption = {73type OpenInOption = {
71 label: string;74 label: string;
...@@ -223,6 +226,10 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -223,6 +226,10 @@ export const OpenInPicker = memo(function OpenInPicker({
223 const panelAnchorRef = useRef<HTMLDivElement | null>(null);226 const panelAnchorRef = useRef<HTMLDivElement | null>(null);
224 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");227 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");
225 const remote = useRemoteOpenState(environmentId);228 const remote = useRemoteOpenState(environmentId);
229 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
230 const isHostEditorDenied = remote.mode === "local-exec" && !canOperateHost;
231 const canOpenEditor = remote.mode !== "remote-unavailable" && !isHostEditorDenied;
232 const [menuOpen, setMenuOpen] = useComposerMenuState(isHostEditorDenied);
226 const remoteCapableEditors = useRemoteCapableEditors();233 const remoteCapableEditors = useRemoteCapableEditors();
227 const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint();234 const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint();
228 const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine";235 const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine";
...@@ -258,6 +265,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -258,6 +265,7 @@ export const OpenInPicker = memo(function OpenInPicker({
258 });265 });
259 return;266 return;
260 }267 }
268 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
261 const result = openInEditorMutation({269 const result = openInEditorMutation({
262 environmentId,270 environmentId,
263 input: {271 input: {
...@@ -285,18 +293,32 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -285,18 +293,32 @@ export const OpenInPicker = memo(function OpenInPicker({
285 );293 );
286294
287 useEffect(() => {295 useEffect(() => {
288 if (!enableShortcut) return;296 if (!enableShortcut || !canOpenEditor) return;
289 const handler = (e: globalThis.KeyboardEvent) => {297 const handler = (e: globalThis.KeyboardEvent) => {
290 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;298 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
291 if (!openInCwd) return;299 if (!openInCwd) return;
292 if (!preferredEditor) return;300 if (!preferredEditor) return;
301 if (
302 remote.mode === "local-exec" &&
303 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
304 )
305 return;
293306
294 e.preventDefault();307 e.preventDefault();
295 void openInEditor(preferredEditor);308 void openInEditor(preferredEditor);
296 };309 };
297 window.addEventListener("keydown", handler);310 window.addEventListener("keydown", handler);
298 return () => window.removeEventListener("keydown", handler);311 return () => window.removeEventListener("keydown", handler);
299 }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]);312 }, [
313 canOpenEditor,
314 enableShortcut,
315 environmentId,
316 keybindings,
317 openInCwd,
318 openInEditor,
319 preferredEditor,
320 remote.mode,
321 ]);
300 const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open";322 const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open";
301323
302 const editorItems = (324 const editorItems = (
...@@ -315,6 +337,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -315,6 +337,7 @@ export const OpenInPicker = memo(function OpenInPicker({
315 {options.map(({ label, Icon, value, kind }) => (337 {options.map(({ label, Icon, value, kind }) => (
316 <MenuItem338 <MenuItem
317 density={presentation === "menu" ? "touch" : "default"}339 density={presentation === "menu" ? "touch" : "default"}
340 disabled={!openInCwd || !canOpenEditor}
318 key={value}341 key={value}
319 onClick={() => openInEditor(value)}342 onClick={() => openInEditor(value)}
320 >343 >
...@@ -341,7 +364,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -341,7 +364,7 @@ export const OpenInPicker = memo(function OpenInPicker({
341 <MenuItem364 <MenuItem
342 density={presentation === "menu" ? "touch" : "default"}365 density={presentation === "menu" ? "touch" : "default"}
343366
344 disabled={!openInCwd || remote.mode === "remote-unavailable"}367 disabled={!openInCwd || !canOpenEditor}
345 onClick={() => openInEditor(preferredEditor)}368 onClick={() => openInEditor(preferredEditor)}
346 >369 >
347 <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />370 <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />
...@@ -352,7 +375,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -352,7 +375,7 @@ export const OpenInPicker = memo(function OpenInPicker({
352 </MenuItem>375 </MenuItem>
353 )}376 )}
354 <MenuSub>377 <MenuSub>
355 <MenuSubTrigger density="touch">378 <MenuSubTrigger density="touch" disabled={isHostEditorDenied}>
356 <SquareArrowOutUpRightIcon className="size-4" />379 <SquareArrowOutUpRightIcon className="size-4" />
357 <MenuItemLabel>Open in…</MenuItemLabel>380 <MenuItemLabel>Open in…</MenuItemLabel>
358 </MenuSubTrigger>381 </MenuSubTrigger>
...@@ -376,7 +399,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -376,7 +399,7 @@ export const OpenInPicker = memo(function OpenInPicker({
376 variant={isPanel ? "ghost" : "outline"}399 variant={isPanel ? "ghost" : "outline"}
377 part="primary"400 part="primary"
378 panel={isPanel}401 panel={isPanel}
379 disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"}402 disabled={!preferredEditor || !openInCwd || !canOpenEditor}
380 onClick={() => openInEditor(preferredEditor)}403 onClick={() => openInEditor(preferredEditor)}
381 >404 >
382 {primaryOption?.Icon ? (405 {primaryOption?.Icon ? (
...@@ -409,8 +432,9 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -409,8 +432,9 @@ export const OpenInPicker = memo(function OpenInPicker({
409 ) : (432 ) : (
410 <GroupSeparator {...(!compact ? { className: "hidden @3xl/header-actions:block" } : {})} />433 <GroupSeparator {...(!compact ? { className: "hidden @3xl/header-actions:block" } : {})} />
411 )}434 )}
412 <Menu>435 <Menu open={menuOpen} onOpenChange={setMenuOpen}>
413 <MenuTrigger436 <MenuTrigger
437 disabled={isHostEditorDenied}
414 render={438 render={
415 <ThreadDetailsControl439 <ThreadDetailsControl
416 aria-label="Choose editor"440 aria-label="Choose editor"
apps/web/src/components/chat/ThreadAutomationsPanel.permissions.test.tsx created+84
...@@ -0,0 +1,84 @@
1import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts";
2import { act, create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() }));
6vi.mock("../../state/session", () => ({
7 useEnvironmentScope: () => state.allowed,
8 readEnvironmentScope: () => state.allowed,
9}));
10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.allowed }));
11vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
12vi.mock("../settings/ScheduledTasksSettings", () => ({
13 relativeLabel: () => "later",
14 scheduleLabel: () => "daily",
15}));
16vi.mock("../../state/server", () => ({
17 serverEnvironment: {
18 scheduledTasksLive: () => null,
19 setScheduledTaskEnabled: "toggle",
20 runScheduledTaskNow: { permissionAtom: () => null },
21 },
22}));
23vi.mock("../../state/use-atom-command", () => ({
24 useAtomCommand: (command: string) => (command === "toggle" ? state.toggle : state.run),
25}));
26vi.mock("../../state/query", () => ({
27 useEnvironmentQuery: () => ({ data: { tasks: [task] }, error: null }),
28}));
29vi.mock("../ui/tooltip", () => ({
30 Tooltip: ({ children }: { children: React.ReactNode }) => children,
31 TooltipTrigger: ({ render }: { render: React.ReactNode }) => render,
32 TooltipPopup: () => null,
33}));
34vi.mock("../ui/toast", () => ({
35 toastManager: { add: vi.fn() },
36 stackedThreadToast: (x: unknown) => x,
37}));
38
39import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel";
40const environmentId = EnvironmentId.make("remote");
41const threadId = ThreadId.make("thread");
42const task = {
43 id: ScheduledTaskId.make("task"),
44 threadId,
45 title: "Daily review",
46 enabled: true,
47 schedule: { type: "interval", everyMs: 60000 },
48 lastRunStatus: "never",
49} as ScheduledTask;
50let renderer: ReactTestRenderer;
51beforeEach(() => {
52 state.allowed = true;
53 state.toggle.mockReset().mockResolvedValue({ _tag: "Success" });
54 state.run.mockReset().mockResolvedValue({ _tag: "Success" });
55});
56afterEach(async () => {
57 if (renderer) await act(async () => renderer.unmount());
58});
59
60it.each([true, false])("runs scheduled work only with task permission (%s)", async (allowed) => {
61 state.allowed = allowed;
62 await act(async () => {
63 renderer = create(<ThreadAutomationsPanel environmentId={environmentId} threadId={threadId} />);
64 });
65 const run = renderer.root.findAllByProps({ "aria-label": "Run Daily review now" })[0]!;
66 await act(async () => {
67 await run.props.onClick();
68 });
69 expect(state.run).toHaveBeenCalledTimes(allowed ? 1 : 0);
70});
71
72it("rejects a retained toggle after task permission is revoked", async () => {
73 await act(async () => {
74 renderer = create(<ThreadAutomationsPanel environmentId={environmentId} threadId={threadId} />);
75 });
76 const toggle = renderer.root
77 .findAllByProps({ "aria-label": "Pause Daily review" })
78 .find((node) => node.props.onCheckedChange)!;
79 state.allowed = false;
80 await act(async () => {
81 await toggle.props.onCheckedChange(false);
82 });
83 expect(state.toggle).not.toHaveBeenCalled();
84});
apps/web/src/components/chat/ThreadAutomationsPanel.tsx+25-5
...@@ -1,8 +1,14 @@...@@ -1,8 +1,14 @@
1import { useAtomValue } from "@effect/atom-react";
1import { ThreadDetailsControl } from "./ThreadDetailsControl";2import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import { useNavigate } from "@tanstack/react-router";3import { useNavigate } from "@tanstack/react-router";
3import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react";4import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react";
4import { useState } from "react";5import { useState } from "react";
5import type { EnvironmentId, ScheduledTask, ThreadId } from "@t3tools/contracts";6import {
7 AuthOrchestrationOperateScope,
8 type EnvironmentId,
9 type ScheduledTask,
10 type ThreadId,
11} from "@t3tools/contracts";
6import {12import {
7 isAtomCommandInterrupted,13 isAtomCommandInterrupted,
8 squashAtomCommandFailure,14 squashAtomCommandFailure,
...@@ -15,6 +21,7 @@ import { useEnvironmentQuery } from "../../state/query";...@@ -15,6 +21,7 @@ import { useEnvironmentQuery } from "../../state/query";
15import { serverEnvironment } from "../../state/server";21import { serverEnvironment } from "../../state/server";
16import { useAtomCommand } from "../../state/use-atom-command";22import { useAtomCommand } from "../../state/use-atom-command";
1723
24import { readEnvironmentScope } from "../../state/session";
18import { Switch } from "../ui/switch";25import { Switch } from "../ui/switch";
19import { stackedThreadToast, toastManager } from "../ui/toast";26import { stackedThreadToast, toastManager } from "../ui/toast";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -40,6 +47,9 @@ export function ThreadAutomationsPanel(props: {...@@ -40,6 +47,9 @@ export function ThreadAutomationsPanel(props: {
40 readonly environmentId: EnvironmentId;47 readonly environmentId: EnvironmentId;
41 readonly threadId: ThreadId;48 readonly threadId: ThreadId;
42}) {49}) {
50 const canOperate = useAtomValue(
51 serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId),
52 );
43 const tasksQuery = useEnvironmentQuery(53 const tasksQuery = useEnvironmentQuery(
44 serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }),54 serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }),
45 );55 );
...@@ -71,7 +81,11 @@ export function ThreadAutomationsPanel(props: {...@@ -71,7 +81,11 @@ export function ThreadAutomationsPanel(props: {
71 };81 };
7282
73 const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => {83 const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => {
74 if (busyTaskId !== null) return;84 if (
85 busyTaskId !== null ||
86 !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)
87 )
88 return;
75 setBusyTaskId(task.id);89 setBusyTaskId(task.id);
76 // Partial update: only the enabled flag changes, so a toggle can never90 // Partial update: only the enabled flag changes, so a toggle can never
77 // revert concurrent edits made to the task elsewhere.91 // revert concurrent edits made to the task elsewhere.
...@@ -86,7 +100,11 @@ export function ThreadAutomationsPanel(props: {...@@ -86,7 +100,11 @@ export function ThreadAutomationsPanel(props: {
86 };100 };
87101
88 const runNow = async (task: ScheduledTask) => {102 const runNow = async (task: ScheduledTask) => {
89 if (busyTaskId !== null) return;103 if (
104 busyTaskId !== null ||
105 !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)
106 )
107 return;
90 setBusyTaskId(task.id);108 setBusyTaskId(task.id);
91 const result = await runTaskNow({109 const result = await runTaskNow({
92 environmentId: props.environmentId,110 environmentId: props.environmentId,
...@@ -196,7 +214,9 @@ export function ThreadAutomationsPanel(props: {...@@ -196,7 +214,9 @@ export function ThreadAutomationsPanel(props: {
196 variant="ghost"214 variant="ghost"
197 part="icon"215 part="icon"
198 aria-label={`Run ${task.title} now`}216 aria-label={`Run ${task.title} now`}
199 disabled={busyTaskId !== null || task.lastRunStatus === "running"}217 disabled={
218 !canOperate || busyTaskId !== null || task.lastRunStatus === "running"
219 }
200 onClick={() => void runNow(task)}220 onClick={() => void runNow(task)}
201 >221 >
202 <PlayIcon className="size-3.5" />222 <PlayIcon className="size-3.5" />
...@@ -208,7 +228,7 @@ export function ThreadAutomationsPanel(props: {...@@ -208,7 +228,7 @@ export function ThreadAutomationsPanel(props: {
208 )}228 )}
209 <Switch229 <Switch
210 checked={task.enabled}230 checked={task.enabled}
211 disabled={busyTaskId !== null}231 disabled={!canOperate || busyTaskId !== null}
212 aria-label={task.enabled ? `Pause ${task.title}` : `Resume ${task.title}`}232 aria-label={task.enabled ? `Pause ${task.title}` : `Resume ${task.title}`}
213 onCheckedChange={(enabled) => void toggleEnabled(task, enabled)}233 onCheckedChange={(enabled) => void toggleEnabled(task, enabled)}
214 />234 />
apps/web/src/components/chat/ThreadDetailsPanel.tsx+2-2
...@@ -170,8 +170,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {...@@ -170,8 +170,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
170170
171 {density !== "essential" && props.showOpenInPicker ? (171 {density !== "essential" && props.showOpenInPicker ? (
172 <OpenInPicker172 <OpenInPicker
173 environmentId={props.environmentId}
174 keybindings={props.keybindings}173 keybindings={props.keybindings}
174 environmentId={props.environmentId}
175 availableEditors={props.availableEditors}175 availableEditors={props.availableEditors}
176 openInCwd={props.gitCwd}176 openInCwd={props.gitCwd}
177 displayMode="panel"177 displayMode="panel"
...@@ -180,10 +180,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {...@@ -180,10 +180,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
180180
181 {props.activeProjectScripts ? (181 {props.activeProjectScripts ? (
182 <ProjectScriptsControl182 <ProjectScriptsControl
183 environmentId={props.environmentId}
183 displayMode="panel"184 displayMode="panel"
184 scripts={props.activeProjectScripts}185 scripts={props.activeProjectScripts}
185 fileScripts={fileScripts}186 fileScripts={fileScripts}
186 keybindings={props.keybindings}
187 preferredScriptId={props.preferredScriptId}187 preferredScriptId={props.preferredScriptId}
188 onRunScript={props.onRunProjectScript}188 onRunScript={props.onRunProjectScript}
189 onAddScript={props.onAddProjectScript}189 onAddScript={props.onAddProjectScript}
apps/web/src/components/chat/TraitsPicker.tsx+5-2
...@@ -581,14 +581,16 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -581,14 +581,16 @@ export const TraitsPicker = memo(function TraitsPicker({
581 isComposerOwned,581 isComposerOwned,
582 size = "sm",582 size = "sm",
583 hidden = false,583 hidden = false,
584 disabled = false,
584 ...persistence585 ...persistence
585}: TraitsMenuContentProps &586}: TraitsMenuContentProps &
586 TraitsPersistence & {587 TraitsPersistence & {
587 size?: ComposerControlSize;588 size?: ComposerControlSize;
588 hidden?: boolean;589 hidden?: boolean;
590 disabled?: boolean;
589 }) {591 }) {
590 const composerFloatingLayerProps = useComposerMenuProps();592 const composerFloatingLayerProps = useComposerMenuProps();
591 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);593 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled);
592 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =594 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
593 getTraitsSectionVisibility({595 getTraitsSectionVisibility({
594 provider,596 provider,
...@@ -627,7 +629,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -627,7 +629,7 @@ export const TraitsPicker = memo(function TraitsPicker({
627 <Menu629 <Menu
628 open={isMenuOpen}630 open={isMenuOpen}
629 onOpenChange={(open) => {631 onOpenChange={(open) => {
630 setIsMenuOpen(open);632 setIsMenuOpen(open && !disabled);
631 }}633 }}
632 >634 >
633 <Tooltip>635 <Tooltip>
...@@ -636,6 +638,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -636,6 +638,7 @@ export const TraitsPicker = memo(function TraitsPicker({
636 <MenuTrigger638 <MenuTrigger
637 render={639 render={
638 <ComposerControl640 <ComposerControl
641 disabled={disabled}
639 aria-label={triggerLabel}642 aria-label={triggerLabel}
640 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}643 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
641 size={size}644 size={size}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+69-19
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import { useAuth } from "@clerk/react";1import { useAuth } from "@clerk/react";
2import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";2import { useAtomValue } from "@effect/atom-react";
3import {
4 AuthAdministrativeScopes,
5 AuthRelayReadScope,
6 AuthRelayWriteScope,
7 type AuthSessionState,
8} from "@t3tools/contracts";
9import { AsyncResult, Atom } from "effect/reactivity";
3import { useEffect, useRef, useState } from "react";10import { useEffect, useRef, useState } from "react";
411
5import {12import {
...@@ -9,9 +16,13 @@ import {...@@ -9,9 +16,13 @@ import {
9} from "~/cloud/connectOnboarding";16} from "~/cloud/connectOnboarding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";17import { hasCloudPublicConfig } from "~/cloud/publicConfig";
11import { useCloudLinkController } from "~/cloud/useCloudLinkController";18import { useCloudLinkController } from "~/cloud/useCloudLinkController";
12import { usePrimarySessionState } from "~/environments/primary";
13import { useLocalStorage } from "~/hooks/useLocalStorage";19import { useLocalStorage } from "~/hooks/useLocalStorage";
14import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";20import {
21 useEnvironments,
22 usePrimaryEnvironment,
23 usePrimaryEnvironmentId,
24} from "~/state/environments";
25import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session";
15import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";26import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
16import { Button } from "../ui/button";27import { Button } from "../ui/button";
17import { Checkbox } from "../ui/checkbox";28import { Checkbox } from "../ui/checkbox";
...@@ -37,6 +48,8 @@ export function ConnectOnboardingDialog() {...@@ -37,6 +48,8 @@ export function ConnectOnboardingDialog() {
3748
38type OnboardingStep = "publish" | "devices";49type OnboardingStep = "publish" | "devices";
3950
51const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());
52
40function ConfiguredConnectOnboardingDialog() {53function ConfiguredConnectOnboardingDialog() {
41 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as54 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as
42 // signed-out, or its later activation would look like a fresh sign-in.55 // signed-out, or its later activation would look like a fresh sign-in.
...@@ -47,22 +60,22 @@ function ConfiguredConnectOnboardingDialog() {...@@ -47,22 +60,22 @@ function ConfiguredConnectOnboardingDialog() {
47 ConnectOnboardingOptOutSchema,60 ConnectOnboardingOptOutSchema,
48 );61 );
4962
50 const desktopBridge = window.desktopBridge;63 const { isReady: environmentsReady } = useEnvironments();
51 const primarySessionState = usePrimarySessionState();64 const primaryEnvironmentId = usePrimaryEnvironmentId();
52 const currentSessionScopes = desktopBridge65 const primarySessionState = useAtomValue(
53 ? AuthAdministrativeScopes66 primaryEnvironmentId === null
54 : primarySessionState.data?.authenticated67 ? EMPTY_SESSION_STATE_ATOM
55 ? (primarySessionState.data.scopes ?? null)68 : environmentSession.sessionStateAtom(primaryEnvironmentId),
56 : null;69 );
57 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;70 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
71 const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
72 const canManageRelay = canReadRelay && canWriteRelay;
58 // The publish step is only offered when we know the answer; opening the73 // The publish step is only offered when we know the answer; opening the
59 // wizard before the session state resolves would let the step set change74 // wizard before the session state resolves would let the step set change
60 // mid-flight. A failed session read still opens the wizard — it just means75 // mid-flight. A failed session read still opens the wizard — it just means
61 // no publish step.76 // no publish step.
62 const sessionScopesKnown =77 const sessionScopesKnown =
63 Boolean(desktopBridge) ||78 primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial";
64 primarySessionState.data !== null ||
65 primarySessionState.error !== null;
6679
67 const controller = useCloudLinkController();80 const controller = useCloudLinkController();
68 const showPublishStep = canManageRelay && controller.linkState.target !== null;81 const showPublishStep = canManageRelay && controller.linkState.target !== null;
...@@ -80,6 +93,12 @@ function ConfiguredConnectOnboardingDialog() {...@@ -80,6 +93,12 @@ function ConfiguredConnectOnboardingDialog() {
80 const prefilledFromLinkStateRef = useRef(false);93 const prefilledFromLinkStateRef = useRef(false);
81 const observedAccountRef = useRef<string | null | undefined>(undefined);94 const observedAccountRef = useRef<string | null | undefined>(undefined);
8295
96 useEffect(() => {
97 if (step === "publish" && sessionScopesKnown && !showPublishStep && !isApplying) {
98 setStep("devices");
99 }
100 }, [isApplying, sessionScopesKnown, showPublishStep, step]);
101
83 const optOutAccounts = optOutState.optOutAccounts;102 const optOutAccounts = optOutState.optOutAccounts;
84103
85 // Every sign-in or account switch that completes during this session104 // Every sign-in or account switch that completes during this session
...@@ -176,6 +195,16 @@ function ConfiguredConnectOnboardingDialog() {...@@ -176,6 +195,16 @@ function ConfiguredConnectOnboardingDialog() {
176 };195 };
177196
178 const applyPublishSelection = async () => {197 const applyPublishSelection = async () => {
198 if (isApplying) return;
199 if (
200 primaryEnvironmentId === null ||
201 !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) ||
202 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
203 ) {
204 setStep("devices");
205 return;
206 }
207 if (linkStateData === null || controller.linkState.error !== null) return;
179 // The wizard only ever enables — with both toggles off there is nothing to208 // The wizard only ever enables — with both toggles off there is nothing to
180 // apply, and an existing link must not be torn down from onboarding.209 // apply, and an existing link must not be torn down from onboarding.
181 if (!exposeEnvironment && !publishAgentActivity) {210 if (!exposeEnvironment && !publishAgentActivity) {
...@@ -225,6 +254,13 @@ function ConfiguredConnectOnboardingDialog() {...@@ -225,6 +254,13 @@ function ConfiguredConnectOnboardingDialog() {
225 isStepDisabled={() => isApplying}254 isStepDisabled={() => isApplying}
226 onStepChange={(index) => {255 onStepChange={(index) => {
227 const next = steps[index];256 const next = steps[index];
257 if (
258 next === "publish" &&
259 (primaryEnvironmentId === null ||
260 !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) ||
261 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope))
262 )
263 return;
228 if (next) setStep(next);264 if (next) setStep(next);
229 }}265 }}
230 />266 />
...@@ -235,10 +271,24 @@ function ConfiguredConnectOnboardingDialog() {...@@ -235,10 +271,24 @@ function ConfiguredConnectOnboardingDialog() {
235 <PublishStep271 <PublishStep
236 exposeEnvironment={exposeEnvironment}272 exposeEnvironment={exposeEnvironment}
237 publishAgentActivity={publishAgentActivity}273 publishAgentActivity={publishAgentActivity}
238 disabled={isApplying}274 disabled={isApplying || !canManageRelay || linkStateData === null}
239 operationError={controller.operationError}275 operationError={controller.operationError ?? controller.linkState.error}
240 onExposeEnvironmentChange={setExposeEnvironment}276 onExposeEnvironmentChange={(enabled) => {
241 onPublishAgentActivityChange={setPublishAgentActivity}277 if (
278 primaryEnvironmentId !== null &&
279 readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) &&
280 readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
281 )
282 setExposeEnvironment(enabled);
283 }}
284 onPublishAgentActivityChange={(enabled) => {
285 if (
286 primaryEnvironmentId !== null &&
287 readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) &&
288 readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
289 )
290 setPublishAgentActivity(enabled);
291 }}
242 />292 />
243 ) : (293 ) : (
244 <DevicesStep />294 <DevicesStep />
...@@ -261,7 +311,7 @@ function ConfiguredConnectOnboardingDialog() {...@@ -261,7 +311,7 @@ function ConfiguredConnectOnboardingDialog() {
261 Not now311 Not now
262 </Button>312 </Button>
263 <Button313 <Button
264 disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}314 disabled={isApplying || !canManageRelay || linkStateData === null}
265 onClick={() => void applyPublishSelection()}315 onClick={() => void applyPublishSelection()}
266 >316 >
267 {isApplying ? "Enabling…" : "Continue"}317 {isApplying ? "Enabling…" : "Continue"}
apps/web/src/components/device/DeviceSetup.tsx+7-3
...@@ -2,6 +2,7 @@ import { DeviceHostUpdates } from "./DeviceHostUpdates";...@@ -2,6 +2,7 @@ import { DeviceHostUpdates } from "./DeviceHostUpdates";
2import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";2import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
3import { Check } from "lucide-react";3import { Check } from "lucide-react";
4import { Check as CheckGlyph, CircleAlert } from "lucide";4import { Check as CheckGlyph, CircleAlert } from "lucide";
5import { AuthSettingsWriteScope } from "@t3tools/contracts";
5import { useState } from "react";6import { useState } from "react";
67
7import { Button } from "~/components/ui/button";8import { Button } from "~/components/ui/button";
...@@ -12,6 +13,7 @@ import { Spinner } from "~/components/ui/spinner";...@@ -12,6 +13,7 @@ import { Spinner } from "~/components/ui/spinner";
12import { Switch } from "~/components/ui/switch";13import { Switch } from "~/components/ui/switch";
13import { deviceEnvironment } from "~/state/device";14import { deviceEnvironment } from "~/state/device";
14import { useAtomCommand } from "~/state/use-atom-command";15import { useAtomCommand } from "~/state/use-atom-command";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
15import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
1618
17const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");19const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");
...@@ -57,6 +59,7 @@ export function DeviceSetup(props: {...@@ -57,6 +59,7 @@ export function DeviceSetup(props: {
57 readonly state: DeviceServiceState;59 readonly state: DeviceServiceState;
58 readonly onComplete?: () => void;60 readonly onComplete?: () => void;
59}) {61}) {
62 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
60 const configure = useAtomCommand(deviceEnvironment.configure);63 const configure = useAtomCommand(deviceEnvironment.configure);
61 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });64 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
62 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);65 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);
...@@ -71,6 +74,7 @@ export function DeviceSetup(props: {...@@ -71,6 +74,7 @@ export function DeviceSetup(props: {
71 kind: NonNullable<typeof pending>,74 kind: NonNullable<typeof pending>,
72 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },75 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },
73 ) => {76 ) => {
77 if (!readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) return;
74 setPending(kind);78 setPending(kind);
75 try {79 try {
76 const result = await configure({ environmentId: props.environmentId, input });80 const result = await configure({ environmentId: props.environmentId, input });
...@@ -103,7 +107,7 @@ export function DeviceSetup(props: {...@@ -103,7 +107,7 @@ export function DeviceSetup(props: {
103 <p className="text-muted-foreground">{deviceHubDescription}</p>107 <p className="text-muted-foreground">{deviceHubDescription}</p>
104 <Switch108 <Switch
105 checked={enabled}109 checked={enabled}
106 disabled={busy || pending !== null}110 disabled={!canConfigure || busy || pending !== null}
107 aria-label="Enable device hub"111 aria-label="Enable device hub"
108 onCheckedChange={(checked) =>112 onCheckedChange={(checked) =>
109 void update("hub", {113 void update("hub", {
...@@ -144,7 +148,7 @@ export function DeviceSetup(props: {...@@ -144,7 +148,7 @@ export function DeviceSetup(props: {
144 <p className="text-muted-foreground">{agentDeviceDescription}</p>148 <p className="text-muted-foreground">{agentDeviceDescription}</p>
145 <Switch149 <Switch
146 checked={props.state.agentAccessEnabled}150 checked={props.state.agentAccessEnabled}
147 disabled={!enabled || busy || pending !== null}151 disabled={!canConfigure || !enabled || busy || pending !== null}
148 aria-label="Allow agents to control devices"152 aria-label="Allow agents to control devices"
149 onCheckedChange={(checked) =>153 onCheckedChange={(checked) =>
150 void update("agent", { agentAccessEnabled: Boolean(checked) })154 void update("agent", { agentAccessEnabled: Boolean(checked) })
...@@ -185,7 +189,7 @@ export function DeviceSetup(props: {...@@ -185,7 +189,7 @@ export function DeviceSetup(props: {
185 </Button>189 </Button>
186 ) : (190 ) : (
187 <Button191 <Button
188 disabled={props.state.hostStatus !== "ready" || pending !== null}192 disabled={!canConfigure || props.state.hostStatus !== "ready" || pending !== null}
189 onClick={() => void update("complete", { onboardingCompleted: true })}193 onClick={() => void update("complete", { onboardingCompleted: true })}
190 >194 >
191 {pending === "complete" ? "Saving…" : "Done"}195 {pending === "complete" ? "Saving…" : "Done"}
apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx created+403
...@@ -0,0 +1,403 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 EnvironmentId,
6 ProjectId,
7 type AgentSessionProjectCandidate,
8 type AuthEnvironmentScope,
9 type AuthSessionState,
10} from "@t3tools/contracts";
11import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
12import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
13import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
14
15type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
16type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string };
17type CreateProjectInput = {
18 environmentId: EnvironmentId;
19 input: { projectId: ProjectId; workspaceRoot: string };
20};
21const state = vi.hoisted(() => ({
22 selectedEnvironment: "primary",
23 registry: null as AtomRegistry.AtomRegistry | null,
24 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
25 projects: null as Atom.Writable<ReadonlyArray<Project>> | null,
26 providers: null as Atom.Atom<ReadonlyArray<never>> | null,
27 config: null as Atom.Atom<null> | null,
28 candidates: [] as AgentSessionProjectCandidate[],
29 createProject: vi.fn(),
30 importThreads: vi.fn(),
31 refreshProviders: vi.fn(),
32 connectPairing: vi.fn(),
33 completeOnboarding: vi.fn(),
34 onDone: vi.fn(),
35 scan: vi.fn(),
36 refreshScan: vi.fn(),
37}));
38
39vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
40vi.mock("@t3tools/client-runtime/state/session", () => ({
41 createEnvironmentSessionAtoms: () => ({
42 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
43 }),
44}));
45vi.mock("../../rpc/atomRegistry", () => ({
46 get appAtomRegistry() {
47 return state.registry;
48 },
49}));
50vi.mock("@clerk/react", () => ({ useAuth: () => ({ isLoaded: true, isSignedIn: false }) }));
51vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => () => {} }));
52vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, () => {}] }));
53vi.mock("../../hooks/useCopyToClipboard", () => ({
54 useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), copied: false }),
55}));
56vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
57vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
58vi.mock("../../onboarding/firstRun", () => ({
59 useCompleteOnboarding: () => state.completeOnboarding,
60}));
61vi.mock("../../env", () => ({ isElectron: false }));
62vi.mock("../../state/environments", () => {
63 const environment = (environmentId: string) => ({
64 environmentId,
65 label: environmentId,
66 connection: { phase: "connected" },
67 entry: { target: { _tag: "DirectConnectionTarget" } },
68 });
69 const primary = environment("primary");
70 return {
71 usePrimaryEnvironment: () => primary,
72 useEnvironments: () => ({ environments: [environment(state.selectedEnvironment)] }),
73 };
74});
75vi.mock("../../state/server", () => ({
76 serverEnvironment: {
77 providersValueAtom: () => state.providers,
78 configValueAtom: () => state.config,
79 refreshProviders: "refreshProviders",
80 },
81}));
82vi.mock("../../state/entities", async () => {
83 const { useAtomValue } = await import("@effect/atom-react");
84 return {
85 useProjects: () => useAtomValue(state.projects!),
86 readProjects: () => state.registry!.get(state.projects!),
87 };
88});
89vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } }));
90vi.mock("../../state/agentSessions", () => ({
91 agentSessionScan: (query: { environmentId: EnvironmentId }) => query,
92 agentSessionImport: "importThreads",
93}));
94vi.mock("../../onboarding/useProjectScans", () => ({
95 useProjectScans: (ids: EnvironmentId[]) =>
96 ids.map((environmentId) => {
97 state.scan({ environmentId });
98 return {
99 environmentId,
100 data: { candidates: state.candidates, truncated: false },
101 isPending: false,
102 error: null,
103 refresh: state.refreshScan,
104 };
105 }),
106}));
107vi.mock("../ui/dialog", () => ({
108 Dialog: "div",
109 DialogPopup: "div",
110 DialogTitle: "div",
111 DialogHeader: "div",
112}));
113vi.mock("../ui/wizard", () => ({
114 WizardPanel: "div",
115 WizardSteps: "div",
116 WizardPopup: "div",
117 WizardHeader: "div",
118}));
119vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
120vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipTrigger: "span", TooltipPopup: "span" }));
121vi.mock("../../connection/onboarding", () => ({ connectPairing: "connectPairing" }));
122vi.mock("../../state/use-atom-command", () => ({
123 useAtomCommand: (
124 command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing",
125 ) => state[command],
126}));
127vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
128vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: "div" }));
129vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: "div" }));
130vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
131vi.mock("../settings/CodexSetupSection", () => ({
132 CodexSetupSection: () => null,
133 AddManagedCodexAccountDialog: () => null,
134}));
135vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Agent" }) }));
136vi.mock("../settings/providerStatus", () => ({
137 getProviderSummary: () => ({ headline: "Checking", detail: null }),
138}));
139vi.mock("../ui/button", () => ({ Button: "button" }));
140vi.mock("../ui/input", () => ({ Input: "input" }));
141vi.mock("../ui/checkbox", () => ({ Checkbox: "input" }));
142vi.mock("../ui/collapsible", () => ({
143 Collapsible: "div",
144 CollapsiblePanel: "div",
145 CollapsibleTrigger: "button",
146}));
147vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), close: vi.fn(), update: vi.fn() } }));
148
149import { WelcomeWizard } from "./WelcomeWizard";
150
151const primaryId = EnvironmentId.make("primary");
152const remoteId = EnvironmentId.make("remote");
153const permissionMessage = "This connection cannot import projects or thread history.";
154let renderer: ReactTestRenderer | undefined;
155
156function session(scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState {
157 return {
158 authenticated: true,
159 scopes: [AuthOrchestrationReadScope, ...scopes],
160 auth: {
161 policy: "remote-reachable",
162 bootstrapMethods: ["one-time-token"],
163 sessionMethods: ["bearer-access-token"],
164 sessionCookieName: "t3_session",
165 },
166 };
167}
168
169function setGrant(environmentId: EnvironmentId, allowed: boolean) {
170 state.registry!.set(
171 state.sessions.get(environmentId)!,
172 AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])),
173 );
174}
175
176function candidate(path: string): AgentSessionProjectCandidate {
177 return {
178 path,
179 title: path.split("/").at(-1)!,
180 sources: ["codex"],
181 threadCount: 10,
182 lastActiveAt: new Date(Date.now() - 60_000).toISOString(),
183 alreadyImported: false,
184 };
185}
186
187function recordProject({ environmentId, input }: CreateProjectInput) {
188 state.registry!.set(state.projects!, [
189 ...state.registry!.get(state.projects!),
190 { id: input.projectId, environmentId, workspaceRoot: input.workspaceRoot },
191 ]);
192 return AsyncResult.success(undefined);
193}
194
195function deferred<T>() {
196 let resolve!: (value: T) => void;
197 const promise = new Promise<T>((complete) => {
198 resolve = complete;
199 });
200 return { promise, resolve };
201}
202
203function text(node: ReactTestInstance | string): string {
204 return typeof node === "string" ? node : node.children.map(text).join("");
205}
206
207function button(label: string) {
208 const found = renderer!.root.findAllByType("button").find((node) => text(node) === label);
209 if (!found) throw new Error(`Missing button: ${label}`);
210 return found;
211}
212
213async function click(label: string) {
214 const target = button(label);
215 expect(target.props.disabled).not.toBe(true);
216 await act(async () => target.props.onClick());
217}
218
219async function mountImport(remote = false) {
220 state.selectedEnvironment = remote ? "remote" : "primary";
221 await act(async () => {
222 renderer = create(
223 <RegistryContext.Provider value={state.registry!}>
224 <WelcomeWizard localAvailable={!remote} onDone={state.onDone} />
225 </RegistryContext.Provider>,
226 );
227 });
228 await click("Continue");
229 await click("Continue");
230 expect(text(renderer!.root)).toContain("Choose your projects");
231}
232
233beforeEach(() => {
234 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
235 vi.stubGlobal("document", { activeElement: null, body: {}, getElementById: () => null });
236 state.registry = AtomRegistry.make();
237 for (const id of [primaryId, remoteId]) {
238 state.sessions.set(
239 id,
240 Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe(
241 Atom.keepAlive,
242 ),
243 );
244 }
245 state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive);
246 state.providers = Atom.make<ReadonlyArray<never>>([]).pipe(Atom.keepAlive);
247 state.config = Atom.make(null).pipe(Atom.keepAlive);
248 state.candidates = [candidate("/projects/first"), candidate("/projects/second")];
249 state.createProject
250 .mockReset()
251 .mockImplementation(async (input: CreateProjectInput) => recordProject(input));
252 state.importThreads
253 .mockReset()
254 .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
255 state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
256 state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
257 state.completeOnboarding.mockReset().mockResolvedValue(undefined);
258 state.onDone.mockReset();
259 state.scan.mockReset();
260 state.refreshScan.mockReset();
261});
262
263afterEach(async () => {
264 await act(async () => renderer?.unmount());
265 renderer = undefined;
266 state.registry?.dispose();
267 state.sessions.clear();
268 vi.unstubAllGlobals();
269});
270
271it("keeps scanning, choosing and skipping available to a paired read-only environment", async () => {
272 setGrant(remoteId, false);
273 await mountImport(true);
274 expect(state.scan).toHaveBeenCalledWith(expect.objectContaining({ environmentId: remoteId }));
275 expect(text(renderer!.root)).toContain("/projects/first");
276 expect(button("Import 2 projects").props.disabled).toBe(true);
277 expect(text(renderer!.root)).toContain(permissionMessage);
278 await click("Do not import projects");
279 expect(state.createProject).not.toHaveBeenCalled();
280 expect(state.importThreads).not.toHaveBeenCalled();
281 expect(state.completeOnboarding).toHaveBeenCalledOnce();
282 expect(state.onDone).toHaveBeenCalledOnce();
283});
284
285it("waits for the selected grant and enables import when it arrives", async () => {
286 state.registry!.set(state.sessions.get(primaryId)!, AsyncResult.initial());
287 await mountImport();
288 expect(button("Import 2 projects").props.disabled).toBe(true);
289 await act(async () => setGrant(primaryId, true));
290 await click("Import 2 projects");
291 expect(state.createProject).toHaveBeenCalledTimes(2);
292 expect(state.importThreads).toHaveBeenCalledTimes(2);
293 expect(state.onDone).toHaveBeenCalledWith(expect.objectContaining({ environmentId: primaryId }));
294});
295
296it("preserves selections and denies a retained action using the paired environment's fresh grant", async () => {
297 await mountImport(true);
298 await act(async () => {
299 renderer!.root.findAllByType("input")[1]!.props.onCheckedChange(false);
300 });
301 const retainedImport = button("Import 1 project").props.onClick;
302 await act(async () => {
303 setGrant(remoteId, false);
304 retainedImport();
305 });
306 expect(button("Import 1 project").props.disabled).toBe(true);
307 expect(renderer!.root.findAllByType("input")[1]!.props.checked).toBe(false);
308 expect(text(renderer!.root)).toContain(permissionMessage);
309 expect(state.createProject).not.toHaveBeenCalled();
310 expect(state.importThreads).not.toHaveBeenCalled();
311 expect(state.onDone).not.toHaveBeenCalled();
312
313 await act(async () => setGrant(remoteId, true));
314 expect(text(renderer!.root)).not.toContain(permissionMessage);
315 await click("Import 1 project");
316 expect(state.createProject).toHaveBeenCalledExactlyOnceWith(
317 expect.objectContaining({ environmentId: remoteId }),
318 );
319 expect(state.importThreads).toHaveBeenCalledExactlyOnceWith(
320 expect.objectContaining({ environmentId: remoteId }),
321 );
322});
323
324it("rechecks after project creation and can retry its history without recreating the project", async () => {
325 const creation = deferred<void>();
326 state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => {
327 await creation.promise;
328 return recordProject(input);
329 });
330 await mountImport();
331 await click("Import 2 projects");
332 expect(state.createProject).toHaveBeenCalledOnce();
333 await act(async () => {
334 setGrant(primaryId, false);
335 creation.resolve();
336 await creation.promise;
337 });
338 expect(state.importThreads).not.toHaveBeenCalled();
339 expect(state.createProject).toHaveBeenCalledOnce();
340 expect(state.onDone).not.toHaveBeenCalled();
341 expect(text(renderer!.root)).toContain(permissionMessage);
342
343 await act(async () => setGrant(primaryId, true));
344 await click("Import 2 projects");
345 expect(state.createProject).toHaveBeenCalledTimes(2);
346 expect(state.importThreads).toHaveBeenCalledTimes(2);
347 expect(state.onDone).toHaveBeenCalledOnce();
348});
349
350it.each([false, true])(
351 "rechecks after history import before the next project write (existing: %s)",
352 async (existing) => {
353 if (existing) {
354 const projectId = ProjectId.make("second-existing");
355 state.candidates[1] = { ...state.candidates[1]!, projectId };
356 state.registry!.set(state.projects!, [
357 { id: projectId, environmentId: primaryId, workspaceRoot: "/projects/second" },
358 ]);
359 }
360 const imported = deferred<{ importedCount: number; skippedCount: number }>();
361 state.importThreads.mockImplementationOnce(async () =>
362 AsyncResult.success(await imported.promise),
363 );
364 await mountImport();
365 await click("Import 2 projects");
366 expect(state.createProject).toHaveBeenCalledOnce();
367 expect(state.importThreads).toHaveBeenCalledOnce();
368 await act(async () => {
369 setGrant(primaryId, false);
370 imported.resolve({ importedCount: 1, skippedCount: 0 });
371 await imported.promise;
372 });
373 expect(state.createProject).toHaveBeenCalledOnce();
374 expect(state.importThreads).toHaveBeenCalledOnce();
375 expect(state.onDone).not.toHaveBeenCalled();
376 expect(text(renderer!.root)).toContain(permissionMessage);
377
378 await act(async () => setGrant(primaryId, true));
379 await click("Import 2 projects");
380 expect(state.createProject).toHaveBeenCalledTimes(existing ? 1 : 2);
381 expect(state.importThreads).toHaveBeenCalledTimes(2);
382 expect(state.onDone).toHaveBeenCalledOnce();
383 },
384);
385
386it("completes accepted imports when permission is revoked after the final server write", async () => {
387 state.candidates = state.candidates.slice(0, 1);
388 const imported = deferred<{ importedCount: number; skippedCount: number }>();
389 state.importThreads.mockImplementationOnce(async () =>
390 AsyncResult.success(await imported.promise),
391 );
392 await mountImport();
393 await click("Import 1 project");
394 await act(async () => {
395 setGrant(primaryId, false);
396 imported.resolve({ importedCount: 1, skippedCount: 0 });
397 await imported.promise;
398 });
399 expect(state.createProject).toHaveBeenCalledOnce();
400 expect(state.importThreads).toHaveBeenCalledOnce();
401 expect(state.completeOnboarding).toHaveBeenCalledOnce();
402 expect(state.onDone).toHaveBeenCalledOnce();
403});
apps/web/src/components/onboarding/WelcomeWizard.test.tsx+6
...@@ -12,6 +12,12 @@ const mocks = vi.hoisted(() => ({...@@ -12,6 +12,12 @@ const mocks = vi.hoisted(() => ({
12 toast: vi.fn(),12 toast: vi.fn(),
13 projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,13 projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,
14}));14}));
15vi.mock("../../state/session", () => ({
16 useEnvironmentScope: () => true,
17 useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) =>
18 new Set(environments.map((entry) => entry.environmentId)),
19 readEnvironmentScope: () => true,
20}));
15vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));21vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
16vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));22vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
17vi.mock("../../state/use-atom-command", () => ({23vi.mock("../../state/use-atom-command", () => ({
apps/web/src/components/onboarding/WelcomeWizard.tsx+39-1
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17import {17import {
18 CommandId,18 CommandId,
19 defaultInstanceIdForDriver,19 defaultInstanceIdForDriver,
20 AuthOrchestrationOperateScope,
20 ProviderDriverKind,21 ProviderDriverKind,
21 ThreadId,22 ThreadId,
22} from "@t3tools/contracts";23} from "@t3tools/contracts";
...@@ -60,6 +61,7 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment...@@ -60,6 +61,7 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment
60import { useProjectScans } from "../../onboarding/useProjectScans";61import { useProjectScans } from "../../onboarding/useProjectScans";
61import { projectEnvironment } from "../../state/projects";62import { projectEnvironment } from "../../state/projects";
62import { serverEnvironment } from "../../state/server";63import { serverEnvironment } from "../../state/server";
64import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
63import { terminalEnvironment } from "../../state/terminal";65import { terminalEnvironment } from "../../state/terminal";
64import { useAtomCommand } from "../../state/use-atom-command";66import { useAtomCommand } from "../../state/use-atom-command";
65import { connectPairing } from "../../connection/onboarding";67import { connectPairing } from "../../connection/onboarding";
...@@ -1145,6 +1147,8 @@ function AgentInstallTerminal({...@@ -1145,6 +1147,8 @@ function AgentInstallTerminal({
11451147
1146// ── Step 4: import ───────────────────────────────────────────1148// ── Step 4: import ───────────────────────────────────────────
11471149
1150const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history.";
1151
1148function ImportStep({1152function ImportStep({
1149 scans,1153 scans,
1150 isImporting,1154 isImporting,
...@@ -1161,6 +1165,7 @@ function ImportStep({...@@ -1161,6 +1165,7 @@ function ImportStep({
1161 ) => Promise<boolean>;1165 ) => Promise<boolean>;
1162}) {1166}) {
1163 const { environments } = useEnvironments();1167 const { environments } = useEnvironments();
1168 const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
1164 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });1169 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
1165 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });1170 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
1166 const projects = useProjects();1171 const projects = useProjects();
...@@ -1222,6 +1227,16 @@ function ImportStep({...@@ -1222,6 +1227,16 @@ function ImportStep({
1222 );1227 );
1223 const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key));1228 const selected = candidates.filter((candidate) => selectedKeys.has(candidate.key));
12241229
1230 const canImport = selected.every((candidate) =>
1231 writableEnvironments.has(candidate.environmentId),
1232 );
1233 const [importError, setImportError] = useState("");
1234 const visibleImportError = !canImport
1235 ? IMPORT_PERMISSION_MESSAGE
1236 : importError === IMPORT_PERMISSION_MESSAGE
1237 ? ""
1238 : importError;
1239
1225 const finishAfterImport = () => {1240 const finishAfterImport = () => {
1226 const projectRef = resolveOnboardingLandingProject(1241 const projectRef = resolveOnboardingLandingProject(
1227 lastImportSelectionRef.current,1242 lastImportSelectionRef.current,
...@@ -1238,6 +1253,18 @@ function ImportStep({...@@ -1238,6 +1253,18 @@ function ImportStep({
12381253
1239 const runImport = async (selection: typeof candidates) => {1254 const runImport = async (selection: typeof candidates) => {
1240 if (isImporting) return;1255 if (isImporting) return;
1256 const hasAccess = () =>
1257 selection.every((candidate) =>
1258 readEnvironmentScope(candidate.environmentId, AuthOrchestrationOperateScope),
1259 );
1260 const stopForDeniedAccess = () => {
1261 setIsImporting(false);
1262 setImportError(IMPORT_PERMISSION_MESSAGE);
1263 };
1264 if (!hasAccess()) {
1265 stopForDeniedAccess();
1266 return;
1267 }
1241 if (selection.length === 0) {1268 if (selection.length === 0) {
1242 void onDone();1269 void onDone();
1243 return;1270 return;
...@@ -1269,6 +1296,10 @@ function ImportStep({...@@ -1269,6 +1296,10 @@ function ImportStep({
1269 ) {1296 ) {
1270 return;1297 return;
1271 }1298 }
1299 if (!hasAccess()) {
1300 stopForDeniedAccess();
1301 return;
1302 }
1272 if (importedProjects.has(candidate.key)) continue;1303 if (importedProjects.has(candidate.key)) continue;
1273 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);1304 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
1274 if (projectId === null) {1305 if (projectId === null) {
...@@ -1308,6 +1339,10 @@ function ImportStep({...@@ -1308,6 +1339,10 @@ function ImportStep({
1308 }1339 }
1309 }1340 }
13101341
1342 if (!hasAccess()) {
1343 stopForDeniedAccess();
1344 return;
1345 }
1311 const threadImportResult = await importThreads({1346 const threadImportResult = await importThreads({
1312 environmentId,1347 environmentId,
1313 input: { projectId, expectedWorkspaceRoot: candidate.path },1348 input: { projectId, expectedWorkspaceRoot: candidate.path },
...@@ -1457,13 +1492,16 @@ function ImportStep({...@@ -1457,13 +1492,16 @@ function ImportStep({
1457 })}1492 })}
1458 </div>1493 </div>
1459 </ScrollArea>1494 </ScrollArea>
1495 {visibleImportError ? (
1496 <p className="mt-3 text-sm text-destructive">{visibleImportError}</p>
1497 ) : null}
1460 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">1498 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">
1461 <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>1499 <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>
1462 Do not import projects1500 Do not import projects
1463 </Button>1501 </Button>
1464 <Button1502 <Button
1465 autoFocus1503 autoFocus
1466 disabled={isImporting || selected.length === 0}1504 disabled={!canImport || isImporting || selected.length === 0}
1467 onClick={() => void runImport(selected)}1505 onClick={() => void runImport(selected)}
1468 >1506 >
1469 {isImporting1507 {isImporting
apps/web/src/components/projectScriptEditor.permissions.test.tsx created+340
...@@ -0,0 +1,340 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthSettingsWriteScope,
4 EnvironmentId,
5 type AuthEnvironmentScope,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { act, create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const permissions = vi.hoisted(() => ({ canWriteSettings: false, canEditProject: true }));
13
14vi.mock("~/state/session", () => ({
15 readEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) =>
16 scope === AuthOrchestrationOperateScope
17 ? permissions.canEditProject
18 : permissions.canWriteSettings,
19 useEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) =>
20 scope === AuthOrchestrationOperateScope
21 ? permissions.canEditProject
22 : permissions.canWriteSettings,
23}));
24vi.mock("./ui/dialog", () => ({
25 Dialog: "div",
26 DialogDescription: "div",
27 DialogFooter: "div",
28 DialogHeader: "div",
29 DialogPanel: "div",
30 DialogPopup: "div",
31 DialogTitle: "div",
32}));
33vi.mock("./ui/alert-dialog", () => ({
34 AlertDialog: "div",
35 AlertDialogClose: "button",
36 AlertDialogDescription: "div",
37 AlertDialogFooter: "div",
38 AlertDialogHeader: "div",
39 AlertDialogPopup: "div",
40 AlertDialogTitle: "div",
41}));
42vi.mock("./ui/popover", () => ({
43 Popover: "div",
44 PopoverPopup: "div",
45 PopoverTrigger: "button",
46}));
47vi.mock("./ui/button", () => ({ Button: "button" }));
48vi.mock("./ui/input", () => ({ Input: "input" }));
49vi.mock("./ui/label", () => ({ Label: "label" }));
50vi.mock("./ui/switch", () => ({ Switch: "input" }));
51vi.mock("./ui/textarea", () => ({ Textarea: "textarea" }));
52
53import {
54 ProjectScriptEditorDialog,
55 type NewProjectScriptInput,
56 type ProjectScriptEditorRequest,
57} from "./projectScriptEditor";
58
59const request: ProjectScriptEditorRequest = {
60 scriptId: "test",
61 initial: {
62 name: "Test",
63 command: "vp test",
64 icon: "test",
65 runOnWorktreeCreate: false,
66 waitForSetup: false,
67 keybinding: "mod+k",
68 previewUrl: null,
69 autoOpenPreview: false,
70 },
71};
72
73describe("ProjectScriptEditorDialog", () => {
74 let renderer: ReactTestRenderer | undefined;
75
76 beforeEach(() => {
77 permissions.canWriteSettings = false;
78 permissions.canEditProject = true;
79 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
80 });
81
82 afterEach(async () => {
83 await act(() => renderer?.unmount());
84 vi.unstubAllGlobals();
85 });
86
87 async function openEditor(
88 onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)),
89 editorRequest = request,
90 actions = { onDelete: vi.fn(), onClose: vi.fn() },
91 editScope:
92 | typeof AuthOrchestrationOperateScope
93 | typeof AuthSettingsWriteScope = AuthOrchestrationOperateScope,
94 ) {
95 await act(() => {
96 renderer = create(
97 <ProjectScriptEditorDialog
98 environmentId={EnvironmentId.make("script-editor-test")}
99 editScope={editScope}
100 request={editorRequest}
101 scripts={[]}
102 onSubmit={onSubmit}
103 onDelete={actions.onDelete}
104 onClose={actions.onClose}
105 />,
106 );
107 });
108 return renderer!.root;
109 }
110
111 it.each([null, "test"])(
112 "saves a settings action without task access (id: %s)",
113 async (scriptId) => {
114 permissions.canEditProject = false;
115 permissions.canWriteSettings = true;
116 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
117 const root = await openEditor(
118 onSubmit,
119 { ...request, scriptId },
120 undefined,
121 AuthSettingsWriteScope,
122 );
123 const submit = root.findByType("form").props.onSubmit;
124 permissions.canWriteSettings = false;
125 await act(async () => {
126 await submit({ preventDefault() {} });
127 });
128 expect(onSubmit).not.toHaveBeenCalled();
129 permissions.canWriteSettings = true;
130 await act(async () => {
131 await submit({ preventDefault() {} });
132 });
133 expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
134 scriptId,
135 expect.objectContaining({ command: "vp test" }),
136 );
137 },
138 );
139
140 it("rechecks settings access before deleting a settings action", async () => {
141 permissions.canEditProject = false;
142 permissions.canWriteSettings = true;
143 const actions = { onDelete: vi.fn(), onClose: vi.fn() };
144 const root = await openEditor(undefined, request, actions, AuthSettingsWriteScope);
145 await act(() => {
146 root.findByProps({ variant: "destructive-outline" }).props.onClick();
147 });
148 const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick;
149 permissions.canWriteSettings = false;
150 await act(() => confirmDelete());
151 expect(actions.onDelete).not.toHaveBeenCalled();
152 permissions.canWriteSettings = true;
153 await act(() => confirmDelete());
154 expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId);
155 });
156
157 it("clears an old shortcut when adding an action with no shortcut", async () => {
158 permissions.canWriteSettings = true;
159 let persistedKeybinding: string | null = "mod+k";
160 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
161 if (input.keybinding !== undefined) persistedKeybinding = input.keybinding;
162 return AsyncResult.success(undefined);
163 });
164 const root = await openEditor(onSubmit, {
165 scriptId: null,
166 initial: { ...request.initial, keybinding: null },
167 });
168
169 await act(async () => {
170 await root.findByType("form").props.onSubmit({ preventDefault() {} });
171 });
172
173 expect(persistedKeybinding).toBeNull();
174 expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null }));
175 });
176
177 it.each([false, true])(
178 "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)",
179 async (revokeBeforeSubmit) => {
180 permissions.canWriteSettings = revokeBeforeSubmit;
181 const persisted = { name: null as string | null, keybinding: "mod+k" as string | null };
182 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
183 if (input.keybinding !== undefined && !permissions.canWriteSettings) {
184 return AsyncResult.failure(
185 Cause.fail(new Error("This connection cannot change keyboard shortcuts.")),
186 );
187 }
188 persisted.name = input.name;
189 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
190 return AsyncResult.success(undefined);
191 });
192 const root = await openEditor(onSubmit, {
193 scriptId: null,
194 initial: { ...request.initial, keybinding: null },
195 });
196
197 permissions.canWriteSettings = false;
198 await act(async () => {
199 await root.findByType("form").props.onSubmit({ preventDefault() {} });
200 });
201
202 expect(persisted).toEqual({ name: "Test", keybinding: "mod+k" });
203 expect(onSubmit).toHaveBeenCalledOnce();
204 },
205 );
206
207 it.each([false, true])(
208 "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)",
209 async (revokeBeforeSubmit) => {
210 permissions.canWriteSettings = true;
211 vi.stubGlobal("navigator", { platform: "Linux" });
212 const persisted = { name: null as string | null, keybinding: "mod+k" as string | null };
213 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
214 persisted.name = input.name;
215 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
216 return AsyncResult.success(undefined);
217 });
218 const root = await openEditor(onSubmit, {
219 scriptId: null,
220 initial: { ...request.initial, keybinding: null },
221 });
222 await act(() => {
223 root.findByProps({ id: "script-keybinding" }).props.onKeyDown({
224 key: "j",
225 ctrlKey: true,
226 metaKey: false,
227 altKey: false,
228 shiftKey: false,
229 preventDefault() {},
230 });
231 });
232 if (revokeBeforeSubmit) permissions.canWriteSettings = false;
233 await act(async () => {
234 await root.findByType("form").props.onSubmit({ preventDefault() {} });
235 });
236
237 expect(persisted).toEqual(
238 revokeBeforeSubmit
239 ? { name: null, keybinding: "mod+k" }
240 : { name: "Test", keybinding: "mod+j" },
241 );
242 },
243 );
244
245 it.each([false, true])(
246 "preserves a concurrent shortcut change when saving only the script (settings access: %s)",
247 async (canWriteSettings) => {
248 permissions.canWriteSettings = canWriteSettings;
249 const persisted = { name: request.initial.name, keybinding: request.initial.keybinding };
250 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
251 persisted.name = input.name;
252 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
253 return AsyncResult.success(undefined);
254 });
255 const root = await openEditor(onSubmit);
256
257 // A second client changes the shortcut after this dialog snapshots it.
258 persisted.keybinding = "mod+j";
259 await act(() => {
260 root.findByProps({ id: "script-name" }).props.onChange({ target: { value: "Renamed" } });
261 });
262 await act(async () => {
263 await root.findByType("form").props.onSubmit({ preventDefault() {} });
264 });
265
266 expect(persisted).toEqual({ name: "Renamed", keybinding: "mod+j" });
267 expect(onSubmit).toHaveBeenCalledOnce();
268 },
269 );
270
271 it.each([false, true])(
272 "enforces settings access when submitting an explicit shortcut clear (revoked: %s)",
273 async (revokeBeforeSubmit) => {
274 permissions.canWriteSettings = true;
275 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
276 const root = await openEditor(onSubmit);
277 await act(() => {
278 root.findByProps({ id: "script-keybinding" }).props.onKeyDown({
279 key: "Backspace",
280 preventDefault() {},
281 });
282 });
283 if (revokeBeforeSubmit) permissions.canWriteSettings = false;
284 await act(async () => {
285 await root.findByType("form").props.onSubmit({ preventDefault() {} });
286 });
287
288 if (revokeBeforeSubmit) {
289 expect(onSubmit).not.toHaveBeenCalled();
290 } else {
291 expect(onSubmit).toHaveBeenCalledWith(
292 "test",
293 expect.objectContaining({ keybinding: null }),
294 );
295 }
296 },
297 );
298
299 it.each([false, true])(
300 "requires project access to save even with settings access (revoked: %s)",
301 async (revokeBeforeSubmit) => {
302 permissions.canEditProject = revokeBeforeSubmit;
303 permissions.canWriteSettings = true;
304 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
305 const root = await openEditor(onSubmit);
306 const submit = root.findByType("form").props.onSubmit;
307
308 permissions.canEditProject = false;
309 await act(async () => {
310 await submit({ preventDefault() {} });
311 });
312 expect(onSubmit).not.toHaveBeenCalled();
313
314 permissions.canEditProject = true;
315 await act(async () => {
316 await submit({ preventDefault() {} });
317 });
318 expect(onSubmit).toHaveBeenCalledOnce();
319 },
320 );
321
322 it("rechecks project access before deleting from an already open confirmation", async () => {
323 const actions = { onDelete: vi.fn(), onClose: vi.fn() };
324 const root = await openEditor(undefined, request, actions);
325 await act(() => {
326 root.findByProps({ variant: "destructive-outline" }).props.onClick();
327 });
328 const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick;
329
330 permissions.canEditProject = false;
331 await act(() => confirmDelete());
332 expect(actions.onDelete).not.toHaveBeenCalled();
333 expect(actions.onClose).not.toHaveBeenCalled();
334
335 permissions.canEditProject = true;
336 await act(() => confirmDelete());
337 expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId);
338 expect(actions.onClose).toHaveBeenCalledOnce();
339 });
340});
apps/web/src/components/projectScriptEditor.test.tsx+6
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
2import { AsyncResult } from "effect/reactivity";3import { AsyncResult } from "effect/reactivity";
3import { act, StrictMode, type ReactNode } from "react";4import { act, StrictMode, type ReactNode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";5import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
67
8vi.mock("~/state/session", () => ({
9 readEnvironmentScope: () => true,
10 useEnvironmentScope: () => true,
11}));
7vi.mock("./ui/dialog", () => ({12vi.mock("./ui/dialog", () => ({
8 Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),13 Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
9 DialogDescription: "p",14 DialogDescription: "p",
...@@ -58,6 +63,7 @@ function editor(nextRequest: ProjectScriptEditorRequest) {...@@ -58,6 +63,7 @@ function editor(nextRequest: ProjectScriptEditorRequest) {
58 return (63 return (
59 <StrictMode>64 <StrictMode>
60 <ProjectScriptEditorDialog65 <ProjectScriptEditorDialog
66 environmentId={EnvironmentId.make("test")}
61 request={nextRequest}67 request={nextRequest}
62 scripts={[]}68 scripts={[]}
63 onSubmit={onSubmit}69 onSubmit={onSubmit}
apps/web/src/components/projectScriptEditor.tsx+46-14
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import type {1import {
2 ProjectScript,2 AuthOrchestrationOperateScope,
3 ProjectScriptIcon,3 AuthSettingsWriteScope,
4 ResolvedKeybindingsConfig,4 type EnvironmentId,
5 type ProjectScript,
6 type ProjectScriptIcon,
7 type ResolvedKeybindingsConfig,
5} from "@t3tools/contracts";8} from "@t3tools/contracts";
6import {9import {
7 isAtomCommandInterrupted,10 isAtomCommandInterrupted,
...@@ -31,6 +34,8 @@ import {...@@ -31,6 +34,8 @@ import {
31} from "~/lib/projectScriptKeybindings";34} from "~/lib/projectScriptKeybindings";
32import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";35import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
33import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";36import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
37import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
38import { useComposerMenuState } from "./chat/useComposerMenuState";
34import {39import {
35 AlertDialog,40 AlertDialog,
36 AlertDialogClose,41 AlertDialogClose,
...@@ -87,7 +92,8 @@ export interface NewProjectScriptInput {...@@ -87,7 +92,8 @@ export interface NewProjectScriptInput {
87 runOnWorktreeCreate: boolean;92 runOnWorktreeCreate: boolean;
88 /** Setup scripts only: hold the agent until the script exits. */93 /** Setup scripts only: hold the agent until the script exits. */
89 waitForSetup: boolean;94 waitForSetup: boolean;
90 keybinding: string | null;95 /** Omit to preserve the current shortcut when the form did not edit it. */
96 keybinding?: string | null;
91 /** Optional URL to open in the in-app preview when this script runs. */97 /** Optional URL to open in the in-app preview when this script runs. */
92 previewUrl: string | null;98 previewUrl: string | null;
93 /** When true, automatically open the preview panel pointed at `previewUrl`. */99 /** When true, automatically open the preview panel pointed at `previewUrl`. */
...@@ -140,12 +146,16 @@ export function editorRequestForScript(...@@ -140,12 +146,16 @@ export function editorRequestForScript(
140 * being edited via `request`; the dialog owns the form state and validation.146 * being edited via `request`; the dialog owns the form state and validation.
141 */147 */
142export function ProjectScriptEditorDialog({148export function ProjectScriptEditorDialog({
149 environmentId,
150 editScope = AuthOrchestrationOperateScope,
143 request,151 request,
144 scripts,152 scripts,
145 onSubmit,153 onSubmit,
146 onDelete,154 onDelete,
147 onClose,155 onClose,
148}: {156}: {
157 environmentId: EnvironmentId;
158 editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
149 request: ProjectScriptEditorRequest | null;159 request: ProjectScriptEditorRequest | null;
150 /** Existing scripts, used to derive a unique id for new scripts. */160 /** Existing scripts, used to derive a unique id for new scripts. */
151 scripts: ReadonlyArray<ProjectScript>;161 scripts: ReadonlyArray<ProjectScript>;
...@@ -156,18 +166,20 @@ export function ProjectScriptEditorDialog({...@@ -156,18 +166,20 @@ export function ProjectScriptEditorDialog({
156 onDelete: (scriptId: string) => void;166 onDelete: (scriptId: string) => void;
157 onClose: () => void;167 onClose: () => void;
158}) {168}) {
169 const canEditActions = useEnvironmentScope(environmentId, editScope);
170 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
159 const formId = React.useId();171 const formId = React.useId();
160 const [name, setName] = useState("");172 const [name, setName] = useState("");
161 const [command, setCommand] = useState("");173 const [command, setCommand] = useState("");
162 const [icon, setIcon] = useState<ProjectScriptIcon>("play");174 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
163 const [iconPickerOpen, setIconPickerOpen] = useState(false);175 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
164 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);176 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
165 const [waitForSetup, setWaitForSetup] = useState(false);177 const [waitForSetup, setWaitForSetup] = useState(false);
166 const [keybinding, setKeybinding] = useState("");178 const [keybinding, setKeybinding] = useState("");
167 const [previewUrl, setPreviewUrl] = useState("");179 const [previewUrl, setPreviewUrl] = useState("");
168 const [autoOpenPreview, setAutoOpenPreview] = useState(false);180 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
169 const [validationError, setValidationError] = useState<string | null>(null);181 const [validationError, setValidationError] = useState<string | null>(null);
170 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);182 const [deleteConfirmOpen, setDeleteConfirmOpen] = useComposerMenuState(!canEditActions);
171 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);183 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);
172 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);184 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
173185
...@@ -199,7 +211,7 @@ export function ProjectScriptEditorDialog({...@@ -199,7 +211,7 @@ export function ProjectScriptEditorDialog({
199 setAutoOpenPreview(request.initial.autoOpenPreview);211 setAutoOpenPreview(request.initial.autoOpenPreview);
200 setValidationError(request.error ?? null);212 setValidationError(request.error ?? null);
201 setSavingRequest(null);213 setSavingRequest(null);
202 }, [request]);214 }, [request, setIconPickerOpen]);
203215
204 const close = () => {216 const close = () => {
205 pendingSubmissionRef.current = null;217 pendingSubmissionRef.current = null;
...@@ -209,6 +221,8 @@ export function ProjectScriptEditorDialog({...@@ -209,6 +221,8 @@ export function ProjectScriptEditorDialog({
209 };221 };
210222
211 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {223 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
224 if (!readEnvironmentScope(environmentId, editScope)) return;
225 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
212 if (event.key === "Tab") return;226 if (event.key === "Tab") return;
213 event.preventDefault();227 event.preventDefault();
214 if (event.key === "Backspace" || event.key === "Delete") {228 if (event.key === "Backspace" || event.key === "Delete") {
...@@ -223,6 +237,17 @@ export function ProjectScriptEditorDialog({...@@ -223,6 +237,17 @@ export function ProjectScriptEditorDialog({
223 const submit = async (event: FormEvent) => {237 const submit = async (event: FormEvent) => {
224 event.preventDefault();238 event.preventDefault();
225 if (!request || pendingSubmissionRef.current !== null) return;239 if (!request || pendingSubmissionRef.current !== null) return;
240 if (!readEnvironmentScope(environmentId, editScope)) {
241 setValidationError("This connection cannot change project actions.");
242 return;
243 }
244 const changesKeybinding =
245 (keybinding.trim() || null) !== (request.initial.keybinding?.trim() || null);
246 const canChangeKeybinding = readEnvironmentScope(environmentId, AuthSettingsWriteScope);
247 if (changesKeybinding && !canChangeKeybinding) {
248 setValidationError("This connection cannot change keyboard shortcuts.");
249 return;
250 }
226 const trimmedName = name.trim();251 const trimmedName = name.trim();
227 const trimmedCommand = command.trim();252 const trimmedCommand = command.trim();
228 if (trimmedName.length === 0) {253 if (trimmedName.length === 0) {
...@@ -254,7 +279,9 @@ export function ProjectScriptEditorDialog({...@@ -254,7 +279,9 @@ export function ProjectScriptEditorDialog({
254 icon,279 icon,
255 runOnWorktreeCreate,280 runOnWorktreeCreate,
256 waitForSetup: runOnWorktreeCreate && waitForSetup,281 waitForSetup: runOnWorktreeCreate && waitForSetup,
257 keybinding: keybindingRule?.key ?? null,282 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding
283 ? { keybinding: keybindingRule?.key ?? null }
284 : {}),
258 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,285 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
259 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,286 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
260 } satisfies NewProjectScriptInput;287 } satisfies NewProjectScriptInput;
...@@ -309,7 +336,7 @@ export function ProjectScriptEditorDialog({...@@ -309,7 +336,7 @@ export function ProjectScriptEditorDialog({
309 </DialogHeader>336 </DialogHeader>
310 <DialogPanel>337 <DialogPanel>
311 <form id={formId} onSubmit={submit}>338 <form id={formId} onSubmit={submit}>
312 <fieldset className="space-y-4" disabled={isSaving}>339 <fieldset className="space-y-4" disabled={isSaving || !canEditActions}>
313 <div className="space-y-1.5">340 <div className="space-y-1.5">
314 <Label htmlFor="script-name">Name</Label>341 <Label htmlFor="script-name">Name</Label>
315 <div className="flex items-center gap-2">342 <div className="flex items-center gap-2">
...@@ -364,6 +391,7 @@ export function ProjectScriptEditorDialog({...@@ -364,6 +391,7 @@ export function ProjectScriptEditorDialog({
364 <div className="space-y-1.5">391 <div className="space-y-1.5">
365 <Label htmlFor="script-keybinding">Keybinding</Label>392 <Label htmlFor="script-keybinding">Keybinding</Label>
366 <Input393 <Input
394 disabled={!canWriteSettings}
367 id="script-keybinding"395 id="script-keybinding"
368 placeholder="Press shortcut"396 placeholder="Press shortcut"
369 value={keybinding}397 value={keybinding}
...@@ -437,7 +465,7 @@ export function ProjectScriptEditorDialog({...@@ -437,7 +465,7 @@ export function ProjectScriptEditorDialog({
437 type="button"465 type="button"
438 variant="destructive-outline"466 variant="destructive-outline"
439 className="mr-auto"467 className="mr-auto"
440 disabled={isSaving}468 disabled={isSaving || !canEditActions}
441 onClick={() => setDeleteConfirmOpen(true)}469 onClick={() => setDeleteConfirmOpen(true)}
442 >470 >
443 Delete471 Delete
...@@ -446,14 +474,14 @@ export function ProjectScriptEditorDialog({...@@ -446,14 +474,14 @@ export function ProjectScriptEditorDialog({
446 <Button type="button" variant="outline" onClick={close}>474 <Button type="button" variant="outline" onClick={close}>
447 Cancel475 Cancel
448 </Button>476 </Button>
449 <Button form={formId} type="submit" disabled={isSaving}>477 <Button form={formId} type="submit" disabled={isSaving || !canEditActions}>
450 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}478 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}
451 </Button>479 </Button>
452 </DialogFooter>480 </DialogFooter>
453 </DialogPopup>481 </DialogPopup>
454 </Dialog>482 </Dialog>
455483
456 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>484 <AlertDialog open={deleteConfirmOpen && canEditActions} onOpenChange={setDeleteConfirmOpen}>
457 <AlertDialogPopup>485 <AlertDialogPopup>
458 <AlertDialogHeader>486 <AlertDialogHeader>
459 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>487 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
...@@ -463,9 +491,13 @@ export function ProjectScriptEditorDialog({...@@ -463,9 +491,13 @@ export function ProjectScriptEditorDialog({
463 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>491 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
464 <Button492 <Button
465 variant="destructive"493 variant="destructive"
466 disabled={isSaving}494 disabled={isSaving || !canEditActions}
467 onClick={() => {495 onClick={() => {
468 if (!request?.scriptId) return;496 if (!request?.scriptId) return;
497 if (!readEnvironmentScope(environmentId, editScope)) {
498 setValidationError("This connection cannot change project actions.");
499 return;
500 }
469 setDeleteConfirmOpen(false);501 setDeleteConfirmOpen(false);
470 close();502 close();
471 onDelete(request.scriptId);503 onDelete(request.scriptId);
apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx+17
...@@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({...@@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({
14 result: null as { readonly agents: ReadonlyArray<AcpRegistrySearchAgent> } | null,14 result: null as { readonly agents: ReadonlyArray<AcpRegistrySearchAgent> } | null,
15 error: null as string | null,15 error: null as string | null,
16 isPending: false,16 isPending: false,
17 canManage: true,
17 refresh: vi.fn(),18 refresh: vi.fn(),
18 search: vi.fn(() => atoms.search),19 search: vi.fn(() => atoms.search),
19 prepare: vi.fn(),20 prepare: vi.fn(),
...@@ -53,6 +54,11 @@ vi.mock("../../state/server", () => ({...@@ -53,6 +54,11 @@ vi.mock("../../state/server", () => ({
53 },54 },
54}));55}));
5556
57vi.mock("../../state/session", () => ({
58 useEnvironmentScope: () => state.canManage,
59 readEnvironmentScope: () => state.canManage,
60}));
61
56vi.mock("../../state/query", () => ({62vi.mock("../../state/query", () => ({
57 useEnvironmentQuery: () => ({63 useEnvironmentQuery: () => ({
58 data: state.result,64 data: state.result,
...@@ -119,6 +125,7 @@ function findByAriaLabel(...@@ -119,6 +125,7 @@ function findByAriaLabel(
119125
120describe("AcpRegistrySearchStep", () => {126describe("AcpRegistrySearchStep", () => {
121 beforeEach(() => {127 beforeEach(() => {
128 state.canManage = true;
122 vi.useFakeTimers();129 vi.useFakeTimers();
123 hooks.reset();130 hooks.reset();
124 state.result = null;131 state.result = null;
...@@ -265,6 +272,16 @@ describe("AcpRegistrySearchStep", () => {...@@ -265,6 +272,16 @@ describe("AcpRegistrySearchStep", () => {
265 });272 });
266 });273 });
267274
275 it("rechecks the grant before preparing an agent", async () => {
276 state.result = { agents: [gemini] };
277 const onPrepared = vi.fn();
278 const add = findByAriaLabel(render({ onPrepared }), "Add Gemini CLI");
279 state.canManage = false;
280 await (add.props.onClick as () => void)();
281 expect(state.prepare).not.toHaveBeenCalled();
282 expect(onPrepared).not.toHaveBeenCalled();
283 });
284
268 it("ignores a stale prepare completion", async () => {285 it("ignores a stale prepare completion", async () => {
269 let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;286 let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
270 let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;287 let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+5-1
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
1import {3import {
2 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
3 squashAtomCommandFailure,5 squashAtomCommandFailure,
...@@ -64,6 +66,7 @@ export function AcpRegistrySearchStep({...@@ -64,6 +66,7 @@ export function AcpRegistrySearchStep({
64 // An empty registry query is the compact compatible catalog. Start there so66 // An empty registry query is the compact compatible catalog. Start there so
65 // entering this step is useful before the user knows what to search for.67 // entering this step is useful before the user knows what to search for.
66 const [submittedQuery, setSubmittedQuery] = useState("");68 const [submittedQuery, setSubmittedQuery] = useState("");
69 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
67 const [preparingId, setPreparingId] = useState<string | null>(null);70 const [preparingId, setPreparingId] = useState<string | null>(null);
68 const [prepareError, setPrepareError] = useState<string | null>(null);71 const [prepareError, setPrepareError] = useState<string | null>(null);
69 const prepareGeneration = useRef(0);72 const prepareGeneration = useRef(0);
...@@ -105,6 +108,7 @@ export function AcpRegistrySearchStep({...@@ -105,6 +108,7 @@ export function AcpRegistrySearchStep({
105 };108 };
106109
107 const handlePrepare = async (agent: AcpRegistrySearchAgent) => {110 const handlePrepare = async (agent: AcpRegistrySearchAgent) => {
111 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
108 const generation = ++prepareGeneration.current;112 const generation = ++prepareGeneration.current;
109 setPrepareError(null);113 setPrepareError(null);
110 setPreparingId(agent.id);114 setPreparingId(agent.id);
...@@ -271,7 +275,7 @@ export function AcpRegistrySearchStep({...@@ -271,7 +275,7 @@ export function AcpRegistrySearchStep({
271 ) : null}275 ) : null}
272 <Button276 <Button
273 aria-label={`${alreadyAdded ? "Already added" : isPreparing ? progressLabel : "Add"} ${agent.name}`}277 aria-label={`${alreadyAdded ? "Already added" : isPreparing ? progressLabel : "Add"} ${agent.name}`}
274 disabled={alreadyAdded || preparingId !== null}278 disabled={!canManageProviders || alreadyAdded || preparingId !== null}
275 onClick={() => void handlePrepare(agent)}279 onClick={() => void handlePrepare(agent)}
276 size="xs"280 size="xs"
277 variant={isPreparing ? "secondary" : "outline"}281 variant={isPreparing ? "secondary" : "outline"}
apps/web/src/components/settings/AcpSessionManagementSection.test.tsx+86-49
...@@ -32,6 +32,12 @@ const commands = vi.hoisted(() => ({...@@ -32,6 +32,12 @@ const commands = vi.hoisted(() => ({
32 logout: vi.fn(),32 logout: vi.fn(),
33}));33}));
3434
35const grants = vi.hoisted(() => ({ scopes: new Set<string>() }));
36vi.mock("~/state/session", async (original) => ({
37 ...(await original<typeof import("~/state/session")>()),
38 useEnvironmentScope: (_id: unknown, scope: string) => grants.scopes.has(scope),
39 readEnvironmentScope: (_id: unknown, scope: string) => grants.scopes.has(scope),
40}));
35const dialogs = vi.hoisted(() => ({ confirm: vi.fn() }));41const dialogs = vi.hoisted(() => ({ confirm: vi.fn() }));
3642
37vi.mock("react", async (importOriginal) => {43vi.mock("react", async (importOriginal) => {
...@@ -105,14 +111,14 @@ const provider = {...@@ -105,14 +111,14 @@ const provider = {
105 skills: [],111 skills: [],
106} satisfies ServerProvider;112} satisfies ServerProvider;
107113
108function render(): ReactElement<Record<string, unknown>> {114function render(readOnly = false): ReactElement<Record<string, unknown>> {
109 hooks.beginRender();115 hooks.beginRender();
110 return AcpSessionManagementSection({116 return AcpSessionManagementSection({
111 environmentId,117 environmentId,
112 instanceId,118 instanceId,
113 provider,119 provider,
114 projects: [{ id: projectId, title: "Antigravity", workspaceRoot: session.cwd }],120 projects: [{ id: projectId, title: "Antigravity", workspaceRoot: session.cwd }],
115 readOnly: false,121 readOnly,
116 }) as ReactElement<Record<string, unknown>>;122 }) as ReactElement<Record<string, unknown>>;
117}123}
118124
...@@ -139,55 +145,56 @@ async function flushPromises(): Promise<void> {...@@ -139,55 +145,56 @@ async function flushPromises(): Promise<void> {
139 await Promise.resolve();145 await Promise.resolve();
140}146}
141147
142describe("AcpSessionManagementSection", () => {148beforeEach(() => {
143 beforeEach(() => {149 hooks.reset();
144 hooks.reset();150 grants.scopes = new Set(["orchestration:read", "orchestration:operate", "providers:manage"]);
145 commands.list.mockReset().mockResolvedValue({151 commands.list.mockReset().mockResolvedValue({
146 _tag: "Success",152 _tag: "Success",
147 value: {153 value: {
148 sessions: [session],154 sessions: [session],
149 nextCursor: null,155 nextCursor: null,
150 canLoad: true,156 canLoad: true,
151 canResume: true,157 canResume: true,
152 canDelete: true,158 canDelete: true,
153 },159 },
154 });160 });
155 commands.import.mockReset().mockResolvedValue({161 commands.import.mockReset().mockResolvedValue({
156 _tag: "Success",162 _tag: "Success",
157 value: { threadId: ThreadId.make("thread-imported"), imported: true },163 value: { threadId: ThreadId.make("thread-imported"), imported: true },
158 });164 });
159 commands.delete.mockReset().mockResolvedValue({165 commands.delete.mockReset().mockResolvedValue({
160 _tag: "Success",166 _tag: "Success",
161 value: { deleted: true },167 value: { deleted: true },
162 });
163 commands.listProviders.mockReset().mockResolvedValue({
164 _tag: "Success",
165 value: {
166 providers: [
167 {
168 providerId: "google",
169 supported: ["openai"],
170 required: false,
171 current: { apiType: "openai", baseUrl: "https://api.example.test/v1" },
172 },
173 ],
174 },
175 });
176 commands.setProvider.mockReset().mockResolvedValue({
177 _tag: "Success",
178 value: { configured: true },
179 });
180 commands.disableProvider.mockReset().mockResolvedValue({
181 _tag: "Success",
182 value: { disabled: true },
183 });
184 commands.logout.mockReset().mockResolvedValue({
185 _tag: "Success",
186 value: { loggedOut: true },
187 });
188 dialogs.confirm.mockReset().mockResolvedValue(true);
189 });168 });
169 commands.listProviders.mockReset().mockResolvedValue({
170 _tag: "Success",
171 value: {
172 providers: [
173 {
174 providerId: "google",
175 supported: ["openai"],
176 required: false,
177 current: { apiType: "openai", baseUrl: "https://api.example.test/v1" },
178 },
179 ],
180 },
181 });
182 commands.setProvider.mockReset().mockResolvedValue({
183 _tag: "Success",
184 value: { configured: true },
185 });
186 commands.disableProvider.mockReset().mockResolvedValue({
187 _tag: "Success",
188 value: { disabled: true },
189 });
190 commands.logout.mockReset().mockResolvedValue({
191 _tag: "Success",
192 value: { loggedOut: true },
193 });
194 dialogs.confirm.mockReset().mockResolvedValue(true);
195});
190196
197describe("AcpSessionManagementSection", () => {
191 it("lists and imports native sessions through the owning environment", async () => {198 it("lists and imports native sessions through the owning environment", async () => {
192 const initial = render();199 const initial = render();
193 (findByLabel(initial, "List sessions").props.onClick as (() => void) | undefined)?.();200 (findByLabel(initial, "List sessions").props.onClick as (() => void) | undefined)?.();
...@@ -292,3 +299,33 @@ describe("AcpSessionManagementSection", () => {...@@ -292,3 +299,33 @@ describe("AcpSessionManagementSection", () => {
292 });299 });
293 });300 });
294});301});
302
303it("imports with task access even when provider configuration is read-only", async () => {
304 grants.scopes.delete("providers:manage");
305 (findByLabel(render(true), "List sessions").props.onClick as () => void)();
306 await flushPromises();
307 (findByLabel(render(true), "Import").props.onClick as () => void)();
308 await flushPromises();
309 expect(commands.import).toHaveBeenCalledOnce();
310});
311
312it("does not import with provider management alone", async () => {
313 grants.scopes.delete("orchestration:operate");
314 (findByLabel(render(), "List sessions").props.onClick as () => void)();
315 await flushPromises();
316 (findByLabel(render(), "Import").props.onClick as () => void)();
317 await flushPromises();
318 expect(commands.import).not.toHaveBeenCalled();
319});
320
321it("rechecks task access after confirming deletion", async () => {
322 (findByLabel(render(), "List sessions").props.onClick as () => void)();
323 await flushPromises();
324 dialogs.confirm.mockImplementationOnce(async () => {
325 grants.scopes.delete("orchestration:operate");
326 return true;
327 });
328 (findByLabel(render(), "Delete").props.onClick as () => void)();
329 await flushPromises();
330 expect(commands.delete).not.toHaveBeenCalled();
331});
apps/web/src/components/settings/AcpSessionManagementSection.tsx+31-12
...@@ -1,3 +1,9 @@...@@ -1,3 +1,9 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthProvidersManageScope,
4 AuthOrchestrationReadScope,
5} from "@t3tools/contracts";
6import { useEnvironmentScope, readEnvironmentScope } from "~/state/session";
1import type {7import type {
2 AcpRegistryConfigurableProvider,8 AcpRegistryConfigurableProvider,
3 AcpRegistrySession,9 AcpRegistrySession,
...@@ -45,6 +51,8 @@ export function AcpSessionManagementSection(props: {...@@ -45,6 +51,8 @@ export function AcpSessionManagementSection(props: {
45 readonly projects: ReadonlyArray<AcpSessionProject>;51 readonly projects: ReadonlyArray<AcpSessionProject>;
46 readonly readOnly: boolean;52 readonly readOnly: boolean;
47}) {53}) {
54 const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
55 const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope);
48 const [projectId, setProjectId] = useState<ProjectId | null>(props.projects[0]?.id ?? null);56 const [projectId, setProjectId] = useState<ProjectId | null>(props.projects[0]?.id ?? null);
49 const [sessions, setSessions] = useState<ReadonlyArray<AcpRegistrySession>>([]);57 const [sessions, setSessions] = useState<ReadonlyArray<AcpRegistrySession>>([]);
50 const [nextCursor, setNextCursor] = useState<string | null>(null);58 const [nextCursor, setNextCursor] = useState<string | null>(null);
...@@ -117,7 +125,12 @@ export function AcpSessionManagementSection(props: {...@@ -117,7 +125,12 @@ export function AcpSessionManagementSection(props: {
117 };125 };
118126
119 const importNativeSession = async (session: AcpRegistrySession) => {127 const importNativeSession = async (session: AcpRegistrySession) => {
120 if (projectId === null || importingSessionId !== null) return;128 if (
129 !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) ||
130 projectId === null ||
131 importingSessionId !== null
132 )
133 return;
121 setImportingSessionId(session.sessionId);134 setImportingSessionId(session.sessionId);
122 const result = await importSession({135 const result = await importSession({
123 environmentId: props.environmentId,136 environmentId: props.environmentId,
...@@ -154,7 +167,8 @@ export function AcpSessionManagementSection(props: {...@@ -154,7 +167,8 @@ export function AcpSessionManagementSection(props: {
154 `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`,167 `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`,
155 { variant: "destructive" },168 { variant: "destructive" },
156 );169 );
157 if (!confirmed) return;170 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope))
171 return;
158 setDeletingSessionId(session.sessionId);172 setDeletingSessionId(session.sessionId);
159 const result = await deleteSession({173 const result = await deleteSession({
160 environmentId: props.environmentId,174 environmentId: props.environmentId,
...@@ -199,7 +213,12 @@ export function AcpSessionManagementSection(props: {...@@ -199,7 +213,12 @@ export function AcpSessionManagementSection(props: {
199 };213 };
200214
201 const saveProvider = async (provider: AcpRegistryConfigurableProvider) => {215 const saveProvider = async (provider: AcpRegistryConfigurableProvider) => {
202 if (projectId === null || savingProviderId !== null) return;216 if (
217 !readEnvironmentScope(props.environmentId, AuthProvidersManageScope) ||
218 projectId === null ||
219 savingProviderId !== null
220 )
221 return;
203 const draft = providerDrafts[provider.providerId];222 const draft = providerDrafts[provider.providerId];
204 if (draft === undefined || draft.apiType.length === 0 || draft.baseUrl.length === 0) return;223 if (draft === undefined || draft.apiType.length === 0 || draft.baseUrl.length === 0) return;
205 let headers: Record<string, string> | undefined;224 let headers: Record<string, string> | undefined;
...@@ -251,7 +270,7 @@ export function AcpSessionManagementSection(props: {...@@ -251,7 +270,7 @@ export function AcpSessionManagementSection(props: {
251 `Disable ACP provider "${provider.providerId}"?`,270 `Disable ACP provider "${provider.providerId}"?`,
252 { variant: "destructive" },271 { variant: "destructive" },
253 );272 );
254 if (!confirmed) return;273 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
255 setSavingProviderId(provider.providerId);274 setSavingProviderId(provider.providerId);
256 const result = await disableProvider({275 const result = await disableProvider({
257 environmentId: props.environmentId,276 environmentId: props.environmentId,
...@@ -267,7 +286,7 @@ export function AcpSessionManagementSection(props: {...@@ -267,7 +286,7 @@ export function AcpSessionManagementSection(props: {
267 };286 };
268287
269 const logoutProvider = async () => {288 const logoutProvider = async () => {
270 if (loggingOut) return;289 if (loggingOut || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
271 setLoggingOut(true);290 setLoggingOut(true);
272 const result = await logout({291 const result = await logout({
273 environmentId: props.environmentId,292 environmentId: props.environmentId,
...@@ -310,7 +329,7 @@ export function AcpSessionManagementSection(props: {...@@ -310,7 +329,7 @@ export function AcpSessionManagementSection(props: {
310 <>329 <>
311 <Select330 <Select
312 value={projectId ?? ""}331 value={projectId ?? ""}
313 disabled={props.readOnly || projectOperationPending}332 disabled={!canRead || projectOperationPending}
314 onValueChange={(value) => {333 onValueChange={(value) => {
315 if (value === null) return;334 if (value === null) return;
316 setProjectId(value as ProjectId);335 setProjectId(value as ProjectId);
...@@ -337,7 +356,7 @@ export function AcpSessionManagementSection(props: {...@@ -337,7 +356,7 @@ export function AcpSessionManagementSection(props: {
337 type="button"356 type="button"
338 size="sm"357 size="sm"
339 variant="outline"358 variant="outline"
340 disabled={props.readOnly || loading || projectId === null}359 disabled={!canRead || loading || projectId === null}
341 onClick={() => void loadSessions()}360 onClick={() => void loadSessions()}
342 >361 >
343 {loading ? "Loading" : sessions.length === 0 ? "List sessions" : "Refresh"}362 {loading ? "Loading" : sessions.length === 0 ? "List sessions" : "Refresh"}
...@@ -370,7 +389,7 @@ export function AcpSessionManagementSection(props: {...@@ -370,7 +389,7 @@ export function AcpSessionManagementSection(props: {
370 size="xs"389 size="xs"
371 variant="ghost-muted"390 variant="ghost-muted"
372 disabled={391 disabled={
373 props.readOnly ||392 !canOperate ||
374 !canImport ||393 !canImport ||
375 session.importedThreadId !== null ||394 session.importedThreadId !== null ||
376 importingSessionId !== null395 importingSessionId !== null
...@@ -389,7 +408,7 @@ export function AcpSessionManagementSection(props: {...@@ -389,7 +408,7 @@ export function AcpSessionManagementSection(props: {
389 size="xs"408 size="xs"
390 variant="ghost-muted"409 variant="ghost-muted"
391 disabled={410 disabled={
392 props.readOnly ||411 !canOperate ||
393 session.importedThreadId !== null ||412 session.importedThreadId !== null ||
394 deletingSessionId !== null413 deletingSessionId !== null
395 }414 }
...@@ -410,7 +429,7 @@ export function AcpSessionManagementSection(props: {...@@ -410,7 +429,7 @@ export function AcpSessionManagementSection(props: {
410 size="xs"429 size="xs"
411 variant="ghost-muted"430 variant="ghost-muted"
412 className="w-fit"431 className="w-fit"
413 disabled={props.readOnly || loading}432 disabled={!canRead || loading}
414 onClick={() => void loadSessions(nextCursor)}433 onClick={() => void loadSessions(nextCursor)}
415 >434 >
416 {loading ? "Loading" : "Load more"}435 {loading ? "Loading" : "Load more"}
...@@ -433,7 +452,7 @@ export function AcpSessionManagementSection(props: {...@@ -433,7 +452,7 @@ export function AcpSessionManagementSection(props: {
433 type="button"452 type="button"
434 size="xs"453 size="xs"
435 variant="outline"454 variant="outline"
436 disabled={props.readOnly || loadingProviders || projectId === null}455 disabled={!canRead || loadingProviders || projectId === null}
437 onClick={() => void loadProviders()}456 onClick={() => void loadProviders()}
438 >457 >
439 {loadingProviders ? "Loading" : providers.length === 0 ? "List providers" : "Refresh"}458 {loadingProviders ? "Loading" : providers.length === 0 ? "List providers" : "Refresh"}
...@@ -443,7 +462,7 @@ export function AcpSessionManagementSection(props: {...@@ -443,7 +462,7 @@ export function AcpSessionManagementSection(props: {
443 {!canList && props.projects.length > 1 ? (462 {!canList && props.projects.length > 1 ? (
444 <Select463 <Select
445 value={projectId ?? ""}464 value={projectId ?? ""}
446 disabled={props.readOnly || projectOperationPending}465 disabled={!canRead || projectOperationPending}
447 onValueChange={(value) => {466 onValueChange={(value) => {
448 if (value === null) return;467 if (value === null) return;
449 setProjectId(value as ProjectId);468 setProjectId(value as ProjectId);
apps/web/src/components/settings/AddCodexAccountDialog.tsx+10-2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
1import { useAtomValue } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
2import {4import {
3 ProviderDriverKind,5 ProviderDriverKind,
...@@ -31,6 +33,7 @@ export function AddCodexAccountDialog({...@@ -31,6 +33,7 @@ export function AddCodexAccountDialog({
31 | undefined;33 | undefined;
32 readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode;34 readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode;
33}) {35}) {
36 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
34 const settings = useEnvironmentSettings(environmentId);37 const settings = useEnvironmentSettings(environmentId);
35 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));38 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
36 const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account");39 const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account");
...@@ -47,7 +50,8 @@ export function AddCodexAccountDialog({...@@ -47,7 +50,8 @@ export function AddCodexAccountDialog({
47 }, [connected]);50 }, [connected]);
4851
49 const createAccount = async () => {52 const createAccount = async () => {
50 if (pending || !name.trim()) return;53 if (pending || !name.trim() || !readEnvironmentScope(environmentId, AuthProvidersManageScope))
54 return;
51 setPending(true);55 setPending(true);
52 // The ID is routing identity; the name is editable and need not be unique.56 // The ID is routing identity; the name is editable and need not be unique.
53 const id = ProviderInstanceId.make(`codex_${randomUUID()}`);57 const id = ProviderInstanceId.make(`codex_${randomUUID()}`);
...@@ -131,7 +135,11 @@ export function AddCodexAccountDialog({...@@ -131,7 +135,11 @@ export function AddCodexAccountDialog({
131 <Button variant="outline" disabled={pending} onClick={onClose}>135 <Button variant="outline" disabled={pending} onClick={onClose}>
132 Cancel136 Cancel
133 </Button>137 </Button>
134 <Button type="submit" form="add-codex-account" disabled={pending || !name.trim()}>138 <Button
139 type="submit"
140 form="add-codex-account"
141 disabled={pending || !name.trim() || !canManageProviders}
142 >
135 {pending ? "Adding account…" : "Continue"}143 {pending ? "Adding account…" : "Continue"}
136 </Button>144 </Button>
137 </>145 </>
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+101
...@@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";...@@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
4import { visitElements } from "../../test/reactElementTree";4import { visitElements } from "../../test/reactElementTree";
5import { reactHookHarness as hooks } from "../../test/reactHookHarness";5import { reactHookHarness as hooks } from "../../test/reactHookHarness";
66
7const actions = vi.hoisted(() => ({
8 update: vi.fn(),
9 toast: vi.fn(),
10 onOpenChange: vi.fn(),
11 canManageProviders: true,
12}));
13
7const settingsHooks = vi.hoisted(() => ({14const settingsHooks = vi.hoisted(() => ({
8 read: vi.fn(() => ({ providerInstances: {} })),15 read: vi.fn(() => ({ providerInstances: {} })),
9 mutate: vi.fn(),16 mutate: vi.fn(),
...@@ -35,6 +42,15 @@ vi.mock("../../hooks/useSettings", () => ({...@@ -35,6 +42,15 @@ vi.mock("../../hooks/useSettings", () => ({
35 usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation,42 usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation,
36}));43}));
3744
45vi.mock("../../state/session", async (importOriginal) => {
46 const actual = await importOriginal<typeof import("../../state/session")>();
47 const hasScope = (environmentId: EnvironmentId, scope: string) =>
48 environmentId === "remote-device" && scope === "providers:manage" && actions.canManageProviders;
49 return { ...actual, useEnvironmentScope: hasScope, readEnvironmentScope: hasScope };
50});
51
52vi.mock("../ui/toast", () => ({ toastManager: { add: actions.toast } }));
53
38import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";54import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
3955
40const remoteEnvironmentId = EnvironmentId.make("remote-device");56const remoteEnvironmentId = EnvironmentId.make("remote-device");
...@@ -81,9 +97,45 @@ async function selectPreparedAcp() {...@@ -81,9 +97,45 @@ async function selectPreparedAcp() {
81 );97 );
82}98}
8399
100function renderDialog() {
101 hooks.beginRender();
102 return AddProviderInstanceDialog({
103 open: true,
104 environmentId: remoteEnvironmentId,
105 environmentLabel: "Remote device",
106 onOpenChange: actions.onOpenChange,
107 });
108}
109
110function button(dialog: unknown, label: string) {
111 const element = visitElements(
112 dialog,
113 (entry) => entry.props.children === label && typeof entry.props.onClick === "function",
114 );
115 if (!element) throw new Error(`Missing button: ${label}`);
116 return element;
117}
118
119function prepareInstance() {
120 let dialog = renderDialog();
121 (button(dialog, "Configure manually").props.onClick as () => void)();
122 dialog = renderDialog();
123 const label = visitElements(dialog, (entry) => entry.props.placeholder === "e.g. Work");
124 if (!label) throw new Error("Missing instance label input.");
125 (label.props.onChange as (event: { target: { value: string } }) => void)({
126 target: { value: "Work" },
127 });
128 dialog = renderDialog();
129 (button(dialog, "Next").props.onClick as () => void)();
130 return renderDialog();
131}
132
84describe("AddProviderInstanceDialog environment routing", () => {133describe("AddProviderInstanceDialog environment routing", () => {
85 beforeEach(() => {134 beforeEach(() => {
86 hooks.reset();135 hooks.reset();
136 actions.canManageProviders = true;
137 actions.toast.mockReset();
138 actions.onOpenChange.mockReset();
87 settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} });139 settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} });
88 settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} });140 settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} });
89 settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate);141 settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate);
...@@ -321,4 +373,53 @@ describe("AddProviderInstanceDialog environment routing", () => {...@@ -321,4 +373,53 @@ describe("AddProviderInstanceDialog environment routing", () => {
321373
322 expect(onOpenChange).not.toHaveBeenCalled();374 expect(onOpenChange).not.toHaveBeenCalled();
323 });375 });
376
377 it("adds an instance with the selected environment's provider grant alone", async () => {
378 const dialog = prepareInstance();
379 (button(dialog, "Add instance").props.onClick as () => void)();
380
381 await Promise.resolve();
382 expect(settingsHooks.mutate).toHaveBeenCalledWith({
383 operation: "create",
384 instanceId: "codex_work",
385 instance: {
386 driver: "codex",
387 enabled: true,
388 displayName: "Work",
389 config: { setupMode: "existing" },
390 },
391 });
392 expect(actions.toast).toHaveBeenCalledWith(
393 expect.objectContaining({ type: "success", title: "Provider instance added" }),
394 );
395 expect(actions.onOpenChange).toHaveBeenCalledWith(false);
396 });
397
398 it("rejects a queued save after the provider grant is revoked", () => {
399 const dialog = prepareInstance();
400 const save = button(dialog, "Add instance").props.onClick as () => void;
401 actions.canManageProviders = false;
402 save();
403
404 expect(settingsHooks.mutate).not.toHaveBeenCalled();
405 expect(actions.toast).not.toHaveBeenCalled();
406 expect(actions.onOpenChange).not.toHaveBeenCalled();
407 expect(button(renderDialog(), "Add instance").props.disabled).toBe(true);
408 });
409
410 it("keeps a denied draft available when the provider grant arrives", async () => {
411 actions.canManageProviders = false;
412 let dialog = prepareInstance();
413 (button(dialog, "Add instance").props.onClick as () => void)();
414 expect(settingsHooks.mutate).not.toHaveBeenCalled();
415 expect(actions.toast).not.toHaveBeenCalled();
416
417 actions.canManageProviders = true;
418 dialog = renderDialog();
419 expect(button(dialog, "Add instance").props.disabled).toBe(false);
420 (button(dialog, "Add instance").props.onClick as () => void)();
421 await Promise.resolve();
422 expect(settingsHooks.mutate).toHaveBeenCalledOnce();
423 expect(actions.onOpenChange).toHaveBeenCalledWith(false);
424 });
324});425});
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+9-1
...@@ -9,6 +9,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"...@@ -9,6 +9,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
9import {9import {
10 DEFAULT_UNIFIED_SETTINGS,10 DEFAULT_UNIFIED_SETTINGS,
11 type AcpRegistrySearchAgent,11 type AcpRegistrySearchAgent,
12 AuthProvidersManageScope,
12 ProviderInstanceId,13 ProviderInstanceId,
13 ProviderDriverKind,14 ProviderDriverKind,
14 type EnvironmentId,15 type EnvironmentId,
...@@ -24,6 +25,7 @@ import * as Equal from "effect/Equal";...@@ -24,6 +25,7 @@ import * as Equal from "effect/Equal";
2425
25import { cn } from "../../lib/utils";26import { cn } from "../../lib/utils";
26import { normalizeProviderAccentColor } from "../../providerInstances";27import { normalizeProviderAccentColor } from "../../providerInstances";
28import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
27import { Button } from "../ui/button";29import { Button } from "../ui/button";
28import { ChatGptConnectionButton } from "./ChatGptConnectionButton";30import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
29import { Dialog } from "../ui/dialog";31import { Dialog } from "../ui/dialog";
...@@ -113,6 +115,7 @@ export function AddProviderInstanceDialog({...@@ -113,6 +115,7 @@ export function AddProviderInstanceDialog({
113}: AddProviderInstanceDialogProps) {115}: AddProviderInstanceDialogProps) {
114 const settings = useEnvironmentSettings(environmentId);116 const settings = useEnvironmentSettings(environmentId);
115 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);117 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
118 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
116119
117 const [wizardStep, setWizardStep] = useState(0);120 const [wizardStep, setWizardStep] = useState(0);
118 const [addingChatGptAccount, setAddingChatGptAccount] = useState(false);121 const [addingChatGptAccount, setAddingChatGptAccount] = useState(false);
...@@ -308,6 +311,7 @@ export function AddProviderInstanceDialog({...@@ -308,6 +311,7 @@ export function AddProviderInstanceDialog({
308 };311 };
309312
310 const handleSave = async () => {313 const handleSave = async () => {
314 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
311 if (isSaving || createdInstanceId) return;315 if (isSaving || createdInstanceId) return;
312 setHasAttemptedSubmit(true);316 setHasAttemptedSubmit(true);
313 if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return;317 if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return;
...@@ -694,7 +698,11 @@ export function AddProviderInstanceDialog({...@@ -694,7 +698,11 @@ export function AddProviderInstanceDialog({
694 Next698 Next
695 </Button>699 </Button>
696 ) : (700 ) : (
697 <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}>701 <Button
702 size="sm"
703 disabled={isSaving || !canManageProviders}
704 onClick={() => void handleSave()}
705 >
698 {isSaving706 {isSaving
699 ? "Adding..."707 ? "Adding..."
700 : isAcpRegistry && !isLocalAcp708 : isAcpRegistry && !isLocalAcp
apps/web/src/components/settings/CodexSetupSection.tsx+4
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { useEnvironmentScope } from "../../state/session";
1import {3import {
2 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
3 squashAtomCommandFailure,5 squashAtomCommandFailure,
...@@ -167,6 +169,7 @@ export function AddManagedCodexAccountDialog({...@@ -167,6 +169,7 @@ export function AddManagedCodexAccountDialog({
167 | ((instanceId: ProviderInstanceId, displayName: string) => void)169 | ((instanceId: ProviderInstanceId, displayName: string) => void)
168 | undefined;170 | undefined;
169}) {171}) {
172 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
170 return (173 return (
171 <AddCodexAccountDialog174 <AddCodexAccountDialog
172 environmentId={environmentId}175 environmentId={environmentId}
...@@ -180,6 +183,7 @@ export function AddManagedCodexAccountDialog({...@@ -180,6 +183,7 @@ export function AddManagedCodexAccountDialog({
180 provider={provider}183 provider={provider}
181 mode="managed"184 mode="managed"
182 enabled185 enabled
186 readOnly={!canManageProviders}
183 autoStart187 autoStart
184 onAutoStartConsumed={noop}188 onAutoStartConsumed={noop}
185 onModeChange={noop}189 onModeChange={noop}
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+13
...@@ -2,6 +2,7 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";...@@ -2,6 +2,7 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";2import { describe, expect, it, vi } from "vite-plus/test";
3import {3import {
4 applyWslEnableSelection,4 applyWslEnableSelection,
5 canRevokeOtherClients,
5 isQrShareableEndpoint,6 isQrShareableEndpoint,
6 isWslSettingsRowVisible,7 isWslSettingsRowVisible,
7 selectQrEndpointOption,8 selectQrEndpointOption,
...@@ -183,3 +184,15 @@ describe("selectQrEndpointOption", () => {...@@ -183,3 +184,15 @@ describe("selectQrEndpointOption", () => {
183 expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();184 expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();
184 });185 });
185});186});
187
188describe("canRevokeOtherClients", () => {
189 it("permits revocation when a write-only grant cannot list clients", () => {
190 expect(canRevokeOtherClients(null)).toBe(true);
191 });
192
193 it("disables revocation only when a loaded list has no other clients", () => {
194 expect(canRevokeOtherClients([])).toBe(false);
195 expect(canRevokeOtherClients([{ current: true }])).toBe(false);
196 expect(canRevokeOtherClients([{ current: true }, { current: false }])).toBe(true);
197 });
198});
apps/web/src/components/settings/ConnectionsSettings.logic.ts+7
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";1import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts";
22
3/** A missing access list says nothing about whether other clients exist. */
4export function canRevokeOtherClients(
5 clientSessions: ReadonlyArray<{ readonly current: boolean }> | null,
6): boolean {
7 return clientSessions === null || clientSessions.some((session) => !session.current);
8}
9
3type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;10type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
411
5/**12/**
apps/web/src/components/settings/ConnectionsSettings.tsx+281-121
...@@ -23,7 +23,9 @@ import {...@@ -23,7 +23,9 @@ import {
23import {23import {
24 AuthAccessReadScope,24 AuthAccessReadScope,
25 AuthAccessWriteScope,25 AuthAccessWriteScope,
26 AuthAdministrativeScopes,26 AuthSettingsWriteScope,
27 AuthProvidersManageScope,
28 AuthEnvironmentMaintainScope,
27 AuthOrchestrationOperateScope,29 AuthOrchestrationOperateScope,
28 AuthOrchestrationReadScope,30 AuthOrchestrationReadScope,
29 AuthRelayReadScope,31 AuthRelayReadScope,
...@@ -64,6 +66,7 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam...@@ -64,6 +66,7 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam
64import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";66import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
65import {67import {
66 applyWslEnableSelection,68 applyWslEnableSelection,
69 canRevokeOtherClients,
67 isQrShareableEndpoint,70 isQrShareableEndpoint,
68 isWslSettingsRowVisible,71 isWslSettingsRowVisible,
69 selectQrEndpointOption,72 selectQrEndpointOption,
...@@ -162,6 +165,7 @@ import {...@@ -162,6 +165,7 @@ import {
162 connectPairing as connectPairingAtom,165 connectPairing as connectPairingAtom,
163 connectSshEnvironment as connectSshEnvironmentAtom,166 connectSshEnvironment as connectSshEnvironmentAtom,
164} from "~/connection/onboarding";167} from "~/connection/onboarding";
168import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
165import { useEnvironmentQuery } from "~/state/query";169import { useEnvironmentQuery } from "~/state/query";
166import {170import {
167 desktopNetworkAccessStateAtom,171 desktopNetworkAccessStateAtom,
...@@ -174,6 +178,7 @@ import {...@@ -174,6 +178,7 @@ import {
174 useEnvironments,178 useEnvironments,
175 usePrimaryEnvironment,179 usePrimaryEnvironment,
176 useRelayEnvironmentDiscovery,180 useRelayEnvironmentDiscovery,
181 usePrimaryEnvironmentId,
177} from "~/state/environments";182} from "~/state/environments";
178import { APP_VERSION } from "~/branding";183import { APP_VERSION } from "~/branding";
179import { requestConfirmDialog } from "~/confirmDialog";184import { requestConfirmDialog } from "~/confirmDialog";
...@@ -234,6 +239,21 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{...@@ -234,6 +239,21 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
234 title: "Operate tasks",239 title: "Operate tasks",
235 description: "Start tasks and perform changes in the environment.",240 description: "Start tasks and perform changes in the environment.",
236 },241 },
242 {
243 scope: AuthSettingsWriteScope,
244 title: "Change environment settings",
245 description: "Edit environment preferences and keybindings.",
246 },
247 {
248 scope: AuthProvidersManageScope,
249 title: "Manage providers",
250 description: "Configure, install, sign in to, and update providers and usage sources.",
251 },
252 {
253 scope: AuthEnvironmentMaintainScope,
254 title: "Maintain environment",
255 description: "Update the server and control environment processes.",
256 },
237 {257 {
238 scope: AuthTerminalOperateScope,258 scope: AuthTerminalOperateScope,
239 title: "Use terminals",259 title: "Use terminals",
...@@ -600,6 +620,7 @@ type PairingLinkListRowProps = {...@@ -600,6 +620,7 @@ type PairingLinkListRowProps = {
600 presentation?: AccessSectionPresentation;620 presentation?: AccessSectionPresentation;
601 revokingPairingLinkId: string | null;621 revokingPairingLinkId: string | null;
602 onRevoke: (id: string) => void;622 onRevoke: (id: string) => void;
623 canRevoke: boolean;
603};624};
604625
605const PairingLinkListRow = memo(function PairingLinkListRow({626const PairingLinkListRow = memo(function PairingLinkListRow({
...@@ -611,6 +632,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -611,6 +632,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
611 presentation = "current",632 presentation = "current",
612 revokingPairingLinkId,633 revokingPairingLinkId,
613 onRevoke,634 onRevoke,
635 canRevoke,
614}: PairingLinkListRowProps) {636}: PairingLinkListRowProps) {
615 const nowMs = useRelativeTimeTick(1_000);637 const nowMs = useRelativeTimeTick(1_000);
616 const expiresAtMs = useMemo(638 const expiresAtMs = useMemo(
...@@ -877,7 +899,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -877,7 +899,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
877 <Button899 <Button
878 size="xs"900 size="xs"
879 variant="destructive-outline"901 variant="destructive-outline"
880 disabled={revokingPairingLinkId === pairingLink.id}902 disabled={!canRevoke || revokingPairingLinkId === pairingLink.id}
881 onClick={() => void onRevoke(pairingLink.id)}903 onClick={() => void onRevoke(pairingLink.id)}
882 >904 >
883 {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"}905 {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"}
...@@ -982,6 +1004,7 @@ type ConnectedClientListRowProps = {...@@ -982,6 +1004,7 @@ type ConnectedClientListRowProps = {
982 presentation?: AccessSectionPresentation;1004 presentation?: AccessSectionPresentation;
983 revokingClientSessionId: string | null;1005 revokingClientSessionId: string | null;
984 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;1006 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
1007 canRevoke: boolean;
985};1008};
9861009
987const ConnectedClientListRow = memo(function ConnectedClientListRow({1010const ConnectedClientListRow = memo(function ConnectedClientListRow({
...@@ -989,6 +1012,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -989,6 +1012,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
989 presentation = "current",1012 presentation = "current",
990 revokingClientSessionId,1013 revokingClientSessionId,
991 onRevokeSession,1014 onRevokeSession,
1015 canRevoke,
992}: ConnectedClientListRowProps) {1016}: ConnectedClientListRowProps) {
993 const nowMs = useRelativeTimeTick(1_000);1017 const nowMs = useRelativeTimeTick(1_000);
994 const isLive = clientSession.current || clientSession.connected;1018 const isLive = clientSession.current || clientSession.connected;
...@@ -1045,7 +1069,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -1045,7 +1069,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
1045 <Button1069 <Button
1046 size="xs"1070 size="xs"
1047 variant="destructive-outline"1071 variant="destructive-outline"
1048 disabled={revokingClientSessionId === clientSession.sessionId}1072 disabled={!canRevoke || revokingClientSessionId === clientSession.sessionId}
1049 onClick={() => void onRevokeSession(clientSession.sessionId)}1073 onClick={() => void onRevokeSession(clientSession.sessionId)}
1050 >1074 >
1051 {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"}1075 {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"}
...@@ -1059,9 +1083,10 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({...@@ -1059,9 +1083,10 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
10591083
1060type AuthorizedClientsHeaderActionProps = {1084type AuthorizedClientsHeaderActionProps = {
1061 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;1085 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;
1062 clientSessions: ReadonlyArray<ServerClientSessionRecord>;1086 clientSessions: ReadonlyArray<ServerClientSessionRecord> | null;
1063 isRevokingOtherClients: boolean;1087 isRevokingOtherClients: boolean;
1064 onRevokeOtherClients: () => void;1088 onRevokeOtherClients: () => void;
1089 delegatableScopes: ReadonlyArray<AuthEnvironmentScope>;
1065};1090};
10661091
1067const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({1092const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({
...@@ -1069,24 +1094,36 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1069,24 +1094,36 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1069 clientSessions,1094 clientSessions,
1070 isRevokingOtherClients,1095 isRevokingOtherClients,
1071 onRevokeOtherClients,1096 onRevokeOtherClients,
1097 delegatableScopes,
1072}: AuthorizedClientsHeaderActionProps) {1098}: AuthorizedClientsHeaderActionProps) {
1099 const primaryEnvironmentId = usePrimaryEnvironmentId();
1073 const [dialogOpen, setDialogOpen] = useState(false);1100 const [dialogOpen, setDialogOpen] = useState(false);
1074 const [pairingLabel, setPairingLabel] = useState("");1101 const [pairingLabel, setPairingLabel] = useState("");
1075 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([1102 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([
1076 ...AuthStandardClientScopes,1103 ...AuthStandardClientScopes,
1077 ]);1104 ]);
1105 const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope));
1078 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);1106 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);
10791107
1080 const handleCreatePairingLink = useCallback(async () => {1108 const handleCreatePairingLink = useCallback(async () => {
1109 if (
1110 primaryEnvironmentId === null ||
1111 selectedScopes.length === 0 ||
1112 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope) ||
1113 !selectedScopes.every((scope) => readEnvironmentScope(primaryEnvironmentId, scope))
1114 )
1115 return;
1081 setIsCreatingPairingLink(true);1116 setIsCreatingPairingLink(true);
1082 try {1117 try {
1083 const created = await createServerPairingCredential({1118 const created = await createServerPairingCredential({
1084 label: pairingLabel,1119 label: pairingLabel,
1085 scopes: pairingScopes,1120 scopes: selectedScopes,
1086 });1121 });
1087 onPairingLinkCreated(created);1122 onPairingLinkCreated(created);
1088 setPairingLabel("");1123 setPairingLabel("");
1089 setPairingScopes([...AuthStandardClientScopes]);1124 setPairingScopes(
1125 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1126 );
1090 setDialogOpen(false);1127 setDialogOpen(false);
1091 } catch (error) {1128 } catch (error) {
1092 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";1129 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
...@@ -1100,7 +1137,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1100,7 +1137,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1100 } finally {1137 } finally {
1101 setIsCreatingPairingLink(false);1138 setIsCreatingPairingLink(false);
1102 }1139 }
1103 }, [onPairingLinkCreated, pairingLabel, pairingScopes]);1140 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
11041141
1105 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {1142 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
1106 setPairingScopes((current) =>1143 setPairingScopes((current) =>
...@@ -1113,9 +1150,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1113,9 +1150,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1113 <Button1150 <Button
1114 size="xs"1151 size="xs"
1115 variant="destructive-outline"1152 variant="destructive-outline"
1116 disabled={1153 disabled={isRevokingOtherClients || !canRevokeOtherClients(clientSessions)}
1117 isRevokingOtherClients || clientSessions.every((clientSession) => clientSession.current)
1118 }
1119 onClick={() => void onRevokeOtherClients()}1154 onClick={() => void onRevokeOtherClients()}
1120 >1155 >
1121 {isRevokingOtherClients ? "Revoking…" : "Revoke others"}1156 {isRevokingOtherClients ? "Revoking…" : "Revoke others"}
...@@ -1126,7 +1161,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1126,7 +1161,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1126 setDialogOpen(open);1161 setDialogOpen(open);
1127 if (!open) {1162 if (!open) {
1128 setPairingLabel("");1163 setPairingLabel("");
1129 setPairingScopes([...AuthStandardClientScopes]);1164 setPairingScopes(
1165 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1166 );
1130 }1167 }
1131 }}1168 }}
1132 >1169 >
...@@ -1172,7 +1209,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1172,7 +1209,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1172 size="xs"1209 size="xs"
1173 variant="outline"1210 variant="outline"
1174 disabled={isCreatingPairingLink}1211 disabled={isCreatingPairingLink}
1175 onClick={() => setPairingScopes([AuthOrchestrationReadScope])}1212 onClick={() =>
1213 setPairingScopes(
1214 delegatableScopes.includes(AuthOrchestrationReadScope)
1215 ? [AuthOrchestrationReadScope]
1216 : [],
1217 )
1218 }
1176 >1219 >
1177 Read only1220 Read only
1178 </Button>1221 </Button>
...@@ -1180,36 +1223,44 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1180,36 +1223,44 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1180 size="xs"1223 size="xs"
1181 variant="outline"1224 variant="outline"
1182 disabled={isCreatingPairingLink}1225 disabled={isCreatingPairingLink}
1183 onClick={() => setPairingScopes([...AuthStandardClientScopes])}1226 onClick={() =>
1227 setPairingScopes(
1228 AuthStandardClientScopes.filter((scope) =>
1229 delegatableScopes.includes(scope),
1230 ),
1231 )
1232 }
1184 >1233 >
1185 Standard1234 Standard
1186 </Button>1235 </Button>
1187 </div>1236 </div>
1188 </div>1237 </div>
1189 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">1238 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">
1190 {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => (1239 {PAIRING_SCOPE_OPTIONS.filter(({ scope }) => delegatableScopes.includes(scope)).map(
1191 <label1240 ({ scope, title, description }) => (
1192 key={scope}1241 <label
1193 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"1242 key={scope}
1194 >1243 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1195 <Checkbox1244 >
1196 className="mt-0.5"1245 <Checkbox
1197 checked={pairingScopes.includes(scope)}1246 className="mt-0.5"
1198 disabled={isCreatingPairingLink}1247 checked={pairingScopes.includes(scope)}
1199 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}1248 disabled={isCreatingPairingLink}
1200 />1249 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1201 <span className="min-w-0">1250 />
1202 <span className="block text-xs font-medium text-foreground">{title}</span>1251 <span className="min-w-0">
1203 <span className="block text-xs leading-snug text-muted-foreground">1252 <span className="block text-xs font-medium text-foreground">{title}</span>
1204 {description}1253 <span className="block text-xs leading-snug text-muted-foreground">
1254 {description}
1255 </span>
1205 </span>1256 </span>
1206 </span>1257 </label>
1207 </label>1258 ),
1208 ))}1259 )}
1209 </div>1260 </div>
1210 {pairingScopes.length === 0 ? (1261 {selectedScopes.length === 0 ? (
1211 <p className="text-xs text-destructive">Select at least one permission.</p>1262 <p className="text-xs text-destructive">Select at least one permission.</p>
1212 ) : pairingScopes.includes(AuthAccessWriteScope) ? (1263 ) : selectedScopes.includes(AuthAccessWriteScope) ? (
1213 <p className="text-xs text-warning">1264 <p className="text-xs text-warning">
1214 This client can create or revoke access for other devices.1265 This client can create or revoke access for other devices.
1215 </p>1266 </p>
...@@ -1225,7 +1276,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1225,7 +1276,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1225 Cancel1276 Cancel
1226 </Button>1277 </Button>
1227 <Button1278 <Button
1228 disabled={isCreatingPairingLink || pairingScopes.length === 0}1279 disabled={isCreatingPairingLink || selectedScopes.length === 0}
1229 onClick={() => void handleCreatePairingLink()}1280 onClick={() => void handleCreatePairingLink()}
1230 >1281 >
1231 {isCreatingPairingLink ? "Creating…" : "Create link"}1282 {isCreatingPairingLink ? "Creating…" : "Create link"}
...@@ -1250,6 +1301,7 @@ type PairingClientsListProps = {...@@ -1250,6 +1301,7 @@ type PairingClientsListProps = {
1250 revokingClientSessionId: string | null;1301 revokingClientSessionId: string | null;
1251 onRevokePairingLink: (id: string) => void;1302 onRevokePairingLink: (id: string) => void;
1252 onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;1303 onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
1304 canRevoke: boolean;
1253};1305};
12541306
1255const PairingClientsList = memo(function PairingClientsList({1307const PairingClientsList = memo(function PairingClientsList({
...@@ -1265,6 +1317,7 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -1265,6 +1317,7 @@ const PairingClientsList = memo(function PairingClientsList({
1265 revokingClientSessionId,1317 revokingClientSessionId,
1266 onRevokePairingLink,1318 onRevokePairingLink,
1267 onRevokeClientSession,1319 onRevokeClientSession,
1320 canRevoke,
1268}: PairingClientsListProps) {1321}: PairingClientsListProps) {
1269 return (1322 return (
1270 <>1323 <>
...@@ -1279,6 +1332,7 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -1279,6 +1332,7 @@ const PairingClientsList = memo(function PairingClientsList({
1279 presentation={presentation}1332 presentation={presentation}
1280 revokingPairingLinkId={revokingPairingLinkId}1333 revokingPairingLinkId={revokingPairingLinkId}
1281 onRevoke={onRevokePairingLink}1334 onRevoke={onRevokePairingLink}
1335 canRevoke={canRevoke}
1282 />1336 />
1283 ))}1337 ))}
12841338
...@@ -1289,6 +1343,7 @@ const PairingClientsList = memo(function PairingClientsList({...@@ -1289,6 +1343,7 @@ const PairingClientsList = memo(function PairingClientsList({
1289 presentation={presentation}1343 presentation={presentation}
1290 revokingClientSessionId={revokingClientSessionId}1344 revokingClientSessionId={revokingClientSessionId}
1291 onRevokeSession={onRevokeClientSession}1345 onRevokeSession={onRevokeClientSession}
1346 canRevoke={canRevoke}
1292 />1347 />
1293 ))}1348 ))}
12941349
...@@ -1793,6 +1848,7 @@ function CloudLinkSwitch({...@@ -1793,6 +1848,7 @@ function CloudLinkSwitch({
1793}1848}
17941849
1795function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {1850function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1851 const primaryEnvironmentId = usePrimaryEnvironmentId();
1796 const {1852 const {
1797 isSignedIn,1853 isSignedIn,
1798 linkState: primaryCloudLinkState,1854 linkState: primaryCloudLinkState,
...@@ -1813,6 +1869,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1813,6 +1869,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1813 const isBusy = isUpdating || isUpdatingPreference;1869 const isBusy = isUpdating || isUpdatingPreference;
18141870
1815 const updateManagedTunnel = async (enabled: boolean) => {1871 const updateManagedTunnel = async (enabled: boolean) => {
1872 if (
1873 primaryEnvironmentId === null ||
1874 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1875 )
1876 return;
1816 setIsUpdating(true);1877 setIsUpdating(true);
1817 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });1878 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
1818 if (ok) {1879 if (ok) {
...@@ -1836,6 +1897,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1836,6 +1897,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1836 };1897 };
18371898
1838 const updatePublishAgentActivity = async (enabled: boolean) => {1899 const updatePublishAgentActivity = async (enabled: boolean) => {
1900 if (
1901 primaryEnvironmentId === null ||
1902 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1903 )
1904 return;
1839 setIsUpdatingPreference(true);1905 setIsUpdatingPreference(true);
1840 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });1906 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
1841 if (ok) {1907 if (ok) {
...@@ -1883,7 +1949,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1883,7 +1949,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1883 control={1949 control={
1884 <CloudLinkSwitch1950 <CloudLinkSwitch
1885 checked={managedTunnelActive}1951 checked={managedTunnelActive}
1886 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}1952 disabled={
1953 !canManageRelay ||
1954 !isSignedIn ||
1955 primaryCloudLinkState.data === null ||
1956 primaryCloudLinkState.isPending ||
1957 isBusy
1958 }
1887 disabledReason={disabledReason}1959 disabledReason={disabledReason}
1888 onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}1960 onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
1889 />1961 />
...@@ -1897,7 +1969,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1897,7 +1969,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1897 <CloudLinkSwitch1969 <CloudLinkSwitch
1898 ariaLabel="Publish agent activity to mobile clients"1970 ariaLabel="Publish agent activity to mobile clients"
1899 checked={publishAgentActivity}1971 checked={publishAgentActivity}
1900 disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy}1972 disabled={
1973 !canManageRelay ||
1974 !isSignedIn ||
1975 primaryCloudLinkState.data === null ||
1976 primaryCloudLinkState.isPending ||
1977 isBusy
1978 }
1901 disabledReason={disabledReason}1979 disabledReason={disabledReason}
1902 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}1980 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
1903 />1981 />
...@@ -1922,8 +2000,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1922,8 +2000,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
1922 );2000 );
1923}2001}
19242002
1925function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {2003function CloudLinkRow({
1926 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;2004 canReadRelay,
2005 canManageRelay,
2006}: {
2007 readonly canReadRelay: boolean;
2008 readonly canManageRelay: boolean;
2009}) {
2010 if (!hasCloudPublicConfig()) return null;
2011 if (!canReadRelay) {
2012 return (
2013 <SettingsRow
2014 title="T3 Connect"
2015 description="To edit these settings, pair this connection with both View relay and Manage relay permissions."
2016 />
2017 );
2018 }
2019 return <ConfiguredCloudLinkRow canManageRelay={canManageRelay} />;
1927}2020}
19282021
1929function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {2022function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {
...@@ -1981,11 +2074,9 @@ export function ConnectionsSettings() {...@@ -1981,11 +2074,9 @@ export function ConnectionsSettings() {
1981 });2074 });
1982 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;2075 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1983 const primarySessionState = usePrimarySessionState();2076 const primarySessionState = usePrimarySessionState();
1984 const currentSessionScopes = desktopBridge2077 const currentSessionScopes = primarySessionState.data?.authenticated
1985 ? AuthAdministrativeScopes2078 ? (primarySessionState.data.scopes ?? null)
1986 : primarySessionState.data?.authenticated2079 : null;
1987 ? (primarySessionState.data.scopes ?? null)
1988 : null;
1989 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);2080 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
1990 // Catalog order is the order the machines were added; rows never jump when2081 // Catalog order is the order the machines were added; rows never jump when
1991 // one is switched off.2082 // one is switched off.
...@@ -2165,12 +2256,14 @@ export function ConnectionsSettings() {...@@ -2165,12 +2256,14 @@ export function ConnectionsSettings() {
2165 const setDefaultAdvertisedEndpointKey = useUiStateStore(2256 const setDefaultAdvertisedEndpointKey = useUiStateStore(
2166 (state) => state.setDefaultAdvertisedEndpointKey,2257 (state) => state.setDefaultAdvertisedEndpointKey,
2167 );2258 );
2168 const canManageLocalBackend =2259 const canReadAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessReadScope);
2169 !isLocalEnvironmentDisabled() &&2260 const canWriteAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope);
2170 (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false);2261 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
2171 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;2262 const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
2263 const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
2264 const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain;
2172 const authAccessChanges = useEnvironmentQuery(2265 const authAccessChanges = useEnvironmentQuery(
2173 canManageLocalBackend && primaryEnvironmentId !== null2266 canReadAccess && primaryEnvironmentId !== null
2174 ? authEnvironment.accessChanges({2267 ? authEnvironment.accessChanges({
2175 environmentId: primaryEnvironmentId,2268 environmentId: primaryEnvironmentId,
2176 input: null,2269 input: null,
...@@ -2273,7 +2366,12 @@ export function ConnectionsSettings() {...@@ -2273,7 +2366,12 @@ export function ConnectionsSettings() {
22732366
2274 const handleDesktopServerExposureChange = useCallback(2367 const handleDesktopServerExposureChange = useCallback(
2275 async (checked: boolean) => {2368 async (checked: boolean) => {
2276 if (!desktopBridge) return;2369 if (
2370 !desktopBridge ||
2371 primaryEnvironmentId === null ||
2372 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2373 )
2374 return;
2277 setIsUpdatingDesktopServerExposure(true);2375 setIsUpdatingDesktopServerExposure(true);
2278 setDesktopServerExposureMutationError(null);2376 setDesktopServerExposureMutationError(null);
2279 try {2377 try {
...@@ -2296,7 +2394,7 @@ export function ConnectionsSettings() {...@@ -2296,7 +2394,7 @@ export function ConnectionsSettings() {
2296 setIsUpdatingDesktopServerExposure(false);2394 setIsUpdatingDesktopServerExposure(false);
2297 }2395 }
2298 },2396 },
2299 [desktopBridge],2397 [desktopBridge, primaryEnvironmentId],
2300 );2398 );
23012399
2302 const handleConfirmDesktopServerExposureChange = useCallback(() => {2400 const handleConfirmDesktopServerExposureChange = useCallback(() => {
...@@ -2306,7 +2404,12 @@ export function ConnectionsSettings() {...@@ -2306,7 +2404,12 @@ export function ConnectionsSettings() {
2306 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);2404 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);
23072405
2308 const handleConfirmTailscaleServeSetup = useCallback(async () => {2406 const handleConfirmTailscaleServeSetup = useCallback(async () => {
2309 if (!desktopBridge) return;2407 if (
2408 !desktopBridge ||
2409 primaryEnvironmentId === null ||
2410 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2411 )
2412 return;
2310 if (!isTailscaleServePortValid) return;2413 if (!isTailscaleServePortValid) return;
2311 setIsUpdatingTailscaleServe(true);2414 setIsUpdatingTailscaleServe(true);
2312 setDesktopServerExposureMutationError(null);2415 setDesktopServerExposureMutationError(null);
...@@ -2331,7 +2434,7 @@ export function ConnectionsSettings() {...@@ -2331,7 +2434,7 @@ export function ConnectionsSettings() {
2331 } finally {2434 } finally {
2332 setIsUpdatingTailscaleServe(false);2435 setIsUpdatingTailscaleServe(false);
2333 }2436 }
2334 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]);2437 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort, primaryEnvironmentId]);
23352438
2336 const handleStartTailscaleServeSetup = useCallback(2439 const handleStartTailscaleServeSetup = useCallback(
2337 (endpoint: AdvertisedEndpoint) => {2440 (endpoint: AdvertisedEndpoint) => {
...@@ -2344,7 +2447,12 @@ export function ConnectionsSettings() {...@@ -2344,7 +2447,12 @@ export function ConnectionsSettings() {
2344 );2447 );
23452448
2346 const handleConfirmTailscaleServeDisable = useCallback(async () => {2449 const handleConfirmTailscaleServeDisable = useCallback(async () => {
2347 if (!desktopBridge) return;2450 if (
2451 !desktopBridge ||
2452 primaryEnvironmentId === null ||
2453 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2454 )
2455 return;
2348 setIsUpdatingTailscaleServe(true);2456 setIsUpdatingTailscaleServe(true);
2349 setDesktopServerExposureMutationError(null);2457 setDesktopServerExposureMutationError(null);
2350 try {2458 try {
...@@ -2367,34 +2475,47 @@ export function ConnectionsSettings() {...@@ -2367,34 +2475,47 @@ export function ConnectionsSettings() {
2367 } finally {2475 } finally {
2368 setIsUpdatingTailscaleServe(false);2476 setIsUpdatingTailscaleServe(false);
2369 }2477 }
2370 }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]);2478 }, [desktopBridge, desktopServerExposureState, primaryEnvironmentId]);
23712479
2372 const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => {2480 const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => {
2373 setDisableTailscaleServeDialogOpen(true);2481 setDisableTailscaleServeDialogOpen(true);
2374 }, []);2482 }, []);
23752483
2376 const handleRevokeDesktopPairingLink = useCallback(async (id: string) => {2484 const handleRevokeDesktopPairingLink = useCallback(
2377 setRevokingDesktopPairingLinkId(id);2485 async (id: string) => {
2378 setDesktopAccessManagementMutationError(null);2486 if (
2379 try {2487 primaryEnvironmentId === null ||
2380 await revokeServerPairingLink(id);2488 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2381 } catch (error) {2489 )
2382 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";2490 return;
2383 setDesktopAccessManagementMutationError(message);2491 setRevokingDesktopPairingLinkId(id);
2384 toastManager.add(2492 setDesktopAccessManagementMutationError(null);
2385 stackedThreadToast({2493 try {
2386 type: "error",2494 await revokeServerPairingLink(id);
2387 title: "Could not revoke pairing link",2495 } catch (error) {
2388 description: message,2496 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
2389 }),2497 setDesktopAccessManagementMutationError(message);
2390 );2498 toastManager.add(
2391 } finally {2499 stackedThreadToast({
2392 setRevokingDesktopPairingLinkId(null);2500 type: "error",
2393 }2501 title: "Could not revoke pairing link",
2394 }, []);2502 description: message,
2503 }),
2504 );
2505 } finally {
2506 setRevokingDesktopPairingLinkId(null);
2507 }
2508 },
2509 [primaryEnvironmentId],
2510 );
23952511
2396 const handleRevokeDesktopClientSession = useCallback(2512 const handleRevokeDesktopClientSession = useCallback(
2397 async (sessionId: ServerClientSessionRecord["sessionId"]) => {2513 async (sessionId: ServerClientSessionRecord["sessionId"]) => {
2514 if (
2515 primaryEnvironmentId === null ||
2516 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2517 )
2518 return;
2398 setRevokingDesktopClientSessionId(sessionId);2519 setRevokingDesktopClientSessionId(sessionId);
2399 setDesktopAccessManagementMutationError(null);2520 setDesktopAccessManagementMutationError(null);
2400 try {2521 try {
...@@ -2413,10 +2534,15 @@ export function ConnectionsSettings() {...@@ -2413,10 +2534,15 @@ export function ConnectionsSettings() {
2413 setRevokingDesktopClientSessionId(null);2534 setRevokingDesktopClientSessionId(null);
2414 }2535 }
2415 },2536 },
2416 [],2537 [primaryEnvironmentId],
2417 );2538 );
24182539
2419 const handleRevokeOtherDesktopClients = useCallback(async () => {2540 const handleRevokeOtherDesktopClients = useCallback(async () => {
2541 if (
2542 primaryEnvironmentId === null ||
2543 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2544 )
2545 return;
2420 setIsRevokingOtherDesktopClients(true);2546 setIsRevokingOtherDesktopClients(true);
2421 setDesktopAccessManagementMutationError(null);2547 setDesktopAccessManagementMutationError(null);
2422 try {2548 try {
...@@ -2439,7 +2565,7 @@ export function ConnectionsSettings() {...@@ -2439,7 +2565,7 @@ export function ConnectionsSettings() {
2439 } finally {2565 } finally {
2440 setIsRevokingOtherDesktopClients(false);2566 setIsRevokingOtherDesktopClients(false);
2441 }2567 }
2442 }, []);2568 }, [primaryEnvironmentId]);
24432569
2444 // Shared by manual SSH submission and discovered-host selection.2570 // Shared by manual SSH submission and discovered-host selection.
2445 const connectSavedBackendSshTarget = useCallback(2571 const connectSavedBackendSshTarget = useCallback(
...@@ -2743,8 +2869,6 @@ export function ConnectionsSettings() {...@@ -2743,8 +2869,6 @@ export function ConnectionsSettings() {
2743 : visibleDesktopNetworkAdvertisedEndpoints,2869 : visibleDesktopNetworkAdvertisedEndpoints,
2744 [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints],2870 [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints],
2745 );2871 );
2746 const isLocalBackendRemotelyReachable =
2747 isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available";
2748 const defaultDesktopNetworkAdvertisedEndpoint = useMemo(2872 const defaultDesktopNetworkAdvertisedEndpoint = useMemo(
2749 () =>2873 () =>
2750 selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey),2874 selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey),
...@@ -3074,7 +3198,12 @@ export function ConnectionsSettings() {...@@ -3074,7 +3198,12 @@ export function ConnectionsSettings() {
3074 // entry catches up (registers/unregisters) without a reload.3198 // entry catches up (registers/unregisters) without a reload.
3075 const applyWslSettingChange = useCallback(3199 const applyWslSettingChange = useCallback(
3076 async (apply: () => Promise<DesktopWslState>) => {3200 async (apply: () => Promise<DesktopWslState>) => {
3077 if (!desktopBridge) return;3201 if (
3202 !desktopBridge ||
3203 primaryEnvironmentId === null ||
3204 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
3205 )
3206 return;
3078 setIsUpdatingWslBackend(true);3207 setIsUpdatingWslBackend(true);
3079 setDesktopWslMutationError(null);3208 setDesktopWslMutationError(null);
3080 try {3209 try {
...@@ -3099,7 +3228,7 @@ export function ConnectionsSettings() {...@@ -3099,7 +3228,7 @@ export function ConnectionsSettings() {
3099 setIsUpdatingWslBackend(false);3228 setIsUpdatingWslBackend(false);
3100 }3229 }
3101 },3230 },
3102 [desktopBridge],3231 [desktopBridge, primaryEnvironmentId],
3103 );3232 );
31043233
3105 // Reload the keep-alive WSL state atom. Clearing the mutation error before3234 // Reload the keep-alive WSL state atom. Clearing the mutation error before
...@@ -3428,6 +3557,7 @@ export function ConnectionsSettings() {...@@ -3428,6 +3557,7 @@ export function ConnectionsSettings() {
3428 revokingClientSessionId={revokingDesktopClientSessionId}3557 revokingClientSessionId={revokingDesktopClientSessionId}
3429 onRevokePairingLink={handleRevokeDesktopPairingLink}3558 onRevokePairingLink={handleRevokeDesktopPairingLink}
3430 onRevokeClientSession={handleRevokeDesktopClientSession}3559 onRevokeClientSession={handleRevokeDesktopClientSession}
3560 canRevoke={canWriteAccess}
3431 />3561 />
3432 </>3562 </>
3433 );3563 );
...@@ -3467,35 +3597,42 @@ export function ConnectionsSettings() {...@@ -3467,35 +3597,42 @@ export function ConnectionsSettings() {
3467 <SettingsRow3597 <SettingsRow
3468 title={searchableSetting("network-access").title}3598 title={searchableSetting("network-access").title}
3469 description={3599 description={
3470 currentAuthPolicy === "remote-reachable"3600 desktopBridge
3471 ? "Remote access is already configured. Change network exposure where the server starts."3601 ? "This connection cannot view or change network exposure."
3472 : "Only this machine can connect. Restart with a non-loopback host for remote pairing."3602 : currentAuthPolicy === "remote-reachable"
3603 ? "Remote access is already configured. Change network exposure where the server starts."
3604 : currentAuthPolicy === "loopback-browser"
3605 ? "Only this machine can connect. Restart with a non-loopback host for remote pairing."
3606 : "Network exposure information is unavailable."
3473 }3607 }
3474 control={3608 control={
3475 <Tooltip>3609 !desktopBridge &&
3476 <TooltipTrigger3610 (currentAuthPolicy === "remote-reachable" || currentAuthPolicy === "loopback-browser") ? (
3477 render={3611 <Tooltip>
3478 <span className="inline-flex">3612 <TooltipTrigger
3479 <Switch3613 render={
3480 checked={isLocalBackendNetworkAccessible}3614 <span className="inline-flex">
3481 disabled3615 <Switch
3482 aria-label="Enable network access"3616 checked={isLocalBackendNetworkAccessible}
3483 />3617 disabled
3484 </span>3618 aria-label="Enable network access"
3485 }3619 />
3486 />3620 </span>
3487 <TooltipPopup side="top">3621 }
3488 Network exposure changes restart the backend and must be controlled where the server3622 />
3489 process is launched.3623 <TooltipPopup side="top">
3490 </TooltipPopup>3624 Network exposure changes restart the backend and must be controlled where the server
3491 </Tooltip>3625 process is launched.
3626 </TooltipPopup>
3627 </Tooltip>
3628 ) : undefined
3492 }3629 }
3493 />3630 />
3494 );3631 );
34953632
3496 const primarySettings = (3633 const primarySettings = (
3497 <>3634 <>
3498 {desktopBridge || canManageLocalBackend ? (3635 {primaryEnvironmentId !== null ? (
3499 <>3636 <>
3500 <SettingsSection3637 <SettingsSection
3501 {...searchableSetting("connections-environment")}3638 {...searchableSetting("connections-environment")}
...@@ -3591,17 +3728,21 @@ export function ConnectionsSettings() {...@@ -3591,17 +3728,21 @@ export function ConnectionsSettings() {
3591 {renderEndpointRows("endpoint-rail")}3728 {renderEndpointRows("endpoint-rail")}
3592 {renderTailscaleRow()}3729 {renderTailscaleRow()}
3593 {renderWslRow()}3730 {renderWslRow()}
3594 <CloudLinkRow canManageRelay={canManageRelay} />3731 {canReadRelay || canManageRelay ? (
3732 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3733 ) : null}
3595 </>3734 </>
3596 ) : canManageLocalBackend ? (3735 ) : canManageLocalBackend ? (
3597 <>3736 <>
3598 {renderDisabledNetworkAccessRow()}3737 {renderDisabledNetworkAccessRow()}
3599 <CloudLinkRow canManageRelay={canManageRelay} />3738 {canReadRelay || canManageRelay ? (
3739 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3740 ) : null}
3600 </>3741 </>
3601 ) : null}3742 ) : null}
3602 </SettingsSection>3743 </SettingsSection>
36033744
3604 {isLocalBackendRemotelyReachable ? (3745 {canReadAccess || canWriteAccess ? (
3605 <FoldedSettingsSection3746 <FoldedSettingsSection
3606 id="authorized-clients"3747 id="authorized-clients"
3607 title="Authorized clients"3748 title="Authorized clients"
...@@ -3610,12 +3751,21 @@ export function ConnectionsSettings() {...@@ -3610,12 +3751,21 @@ export function ConnectionsSettings() {
3610 visibleDesktopPairingLinks,3751 visibleDesktopPairingLinks,
3611 )}3752 )}
3612 control={3753 control={
3613 <AuthorizedClientsHeaderAction3754 canWriteAccess ? (
3614 onPairingLinkCreated={handlePairingLinkCreated}3755 <AuthorizedClientsHeaderAction
3615 clientSessions={desktopClientSessions}3756 delegatableScopes={currentSessionScopes ?? []}
3616 isRevokingOtherClients={isRevokingOtherDesktopClients}3757 onPairingLinkCreated={handlePairingLinkCreated}
3617 onRevokeOtherClients={handleRevokeOtherDesktopClients}3758 clientSessions={
3618 />3759 canReadAccess &&
3760 authAccessChanges.error === null &&
3761 authAccessChanges.data?.type === "snapshot"
3762 ? desktopClientSessions
3763 : null
3764 }
3765 isRevokingOtherClients={isRevokingOtherDesktopClients}
3766 onRevokeOtherClients={handleRevokeOtherDesktopClients}
3767 />
3768 ) : undefined
3619 }3769 }
3620 >3770 >
3621 <ScrollArea3771 <ScrollArea
...@@ -3624,12 +3774,18 @@ export function ConnectionsSettings() {...@@ -3624,12 +3774,18 @@ export function ConnectionsSettings() {
3624 className="max-h-[22.5rem]"3774 className="max-h-[22.5rem]"
3625 data-testid="authorized-clients-scroll-area"3775 data-testid="authorized-clients-scroll-area"
3626 >3776 >
3627 {renderAuthorizedClients("current")}3777 {canReadAccess ? (
3778 renderAuthorizedClients("current")
3779 ) : (
3780 <p className="px-4 py-3 text-xs text-muted-foreground">
3781 This connection can create access links but cannot view authorized clients.
3782 </p>
3783 )}
3628 </ScrollArea>3784 </ScrollArea>
3629 </FoldedSettingsSection>3785 </FoldedSettingsSection>
3630 ) : null}3786 ) : null}
3631 <AlertDialog3787 <AlertDialog
3632 open={isDesktopServerExposureDialogOpen}3788 open={isDesktopServerExposureDialogOpen && canManageLocalBackend}
3633 onOpenChange={(open) => {3789 onOpenChange={(open) => {
3634 if (isUpdatingDesktopServerExposure) return;3790 if (isUpdatingDesktopServerExposure) return;
3635 setIsDesktopServerExposureDialogOpen(open);3791 setIsDesktopServerExposureDialogOpen(open);
...@@ -3662,7 +3818,9 @@ export function ConnectionsSettings() {...@@ -3662,7 +3818,9 @@ export function ConnectionsSettings() {
3662 variant="default"3818 variant="default"
3663 onClick={handleConfirmDesktopServerExposureChange}3819 onClick={handleConfirmDesktopServerExposureChange}
3664 disabled={3820 disabled={
3665 pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure3821 !canManageLocalBackend ||
3822 pendingDesktopServerExposureMode === null ||
3823 isUpdatingDesktopServerExposure
3666 }3824 }
3667 >3825 >
3668 {isUpdatingDesktopServerExposure && <Spinner size="sm" />}3826 {isUpdatingDesktopServerExposure && <Spinner size="sm" />}
...@@ -3678,7 +3836,7 @@ export function ConnectionsSettings() {...@@ -3678,7 +3836,7 @@ export function ConnectionsSettings() {
3678 </AlertDialogPopup>3836 </AlertDialogPopup>
3679 </AlertDialog>3837 </AlertDialog>
3680 <AlertDialog3838 <AlertDialog
3681 open={isWslConfirmDialogOpen}3839 open={isWslConfirmDialogOpen && canManageLocalBackend}
3682 onOpenChange={(open) => {3840 onOpenChange={(open) => {
3683 if (isUpdatingWslBackend) return;3841 if (isUpdatingWslBackend) return;
3684 if (!open) setPendingWslChange(null);3842 if (!open) setPendingWslChange(null);
...@@ -3725,7 +3883,7 @@ export function ConnectionsSettings() {...@@ -3725,7 +3883,7 @@ export function ConnectionsSettings() {
3725 <Button3883 <Button
3726 variant="outline"3884 variant="outline"
3727 onClick={() => handleConfirmEnableWsl("wsl-only")}3885 onClick={() => handleConfirmEnableWsl("wsl-only")}
3728 disabled={isUpdatingWslBackend}3886 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3729 >3887 >
3730 {isUpdatingWslBackend ? (3888 {isUpdatingWslBackend ? (
3731 <>3889 <>
...@@ -3739,7 +3897,7 @@ export function ConnectionsSettings() {...@@ -3739,7 +3897,7 @@ export function ConnectionsSettings() {
3739 <Button3897 <Button
3740 variant="default"3898 variant="default"
3741 onClick={() => handleConfirmEnableWsl("both")}3899 onClick={() => handleConfirmEnableWsl("both")}
3742 disabled={isUpdatingWslBackend}3900 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3743 >3901 >
3744 {isUpdatingWslBackend ? (3902 {isUpdatingWslBackend ? (
3745 <>3903 <>
...@@ -3760,7 +3918,7 @@ export function ConnectionsSettings() {...@@ -3760,7 +3918,7 @@ export function ConnectionsSettings() {
3760 : "default"3918 : "default"
3761 }3919 }
3762 onClick={handleConfirmWslChange}3920 onClick={handleConfirmWslChange}
3763 disabled={isUpdatingWslBackend}3921 disabled={isUpdatingWslBackend || !canManageLocalBackend}
3764 >3922 >
3765 {isUpdatingWslBackend ? (3923 {isUpdatingWslBackend ? (
3766 <>3924 <>
...@@ -3786,7 +3944,7 @@ export function ConnectionsSettings() {...@@ -3786,7 +3944,7 @@ export function ConnectionsSettings() {
3786 </AlertDialogPopup>3944 </AlertDialogPopup>
3787 </AlertDialog>3945 </AlertDialog>
3788 <AlertDialog3946 <AlertDialog
3789 open={disableTailscaleServeDialogOpen}3947 open={disableTailscaleServeDialogOpen && canManageLocalBackend}
3790 onOpenChange={(open) => {3948 onOpenChange={(open) => {
3791 if (isUpdatingTailscaleServe) return;3949 if (isUpdatingTailscaleServe) return;
3792 setDisableTailscaleServeDialogOpen(open);3950 setDisableTailscaleServeDialogOpen(open);
...@@ -3809,7 +3967,7 @@ export function ConnectionsSettings() {...@@ -3809,7 +3967,7 @@ export function ConnectionsSettings() {
3809 <Button3967 <Button
3810 variant="destructive"3968 variant="destructive"
3811 onClick={() => void handleConfirmTailscaleServeDisable()}3969 onClick={() => void handleConfirmTailscaleServeDisable()}
3812 disabled={isUpdatingTailscaleServe}3970 disabled={isUpdatingTailscaleServe || !canManageLocalBackend}
3813 >3971 >
3814 {isUpdatingTailscaleServe ? (3972 {isUpdatingTailscaleServe ? (
3815 <>3973 <>
...@@ -3899,10 +4057,12 @@ export function ConnectionsSettings() {...@@ -3899,10 +4057,12 @@ export function ConnectionsSettings() {
3899 ) : (4057 ) : (
3900 <SettingsSection {...searchableSetting("connections-environment")}>4058 <SettingsSection {...searchableSetting("connections-environment")}>
3901 <SettingsRow4059 <SettingsRow
3902 title="Administrative access"4060 title="No environment selected"
3903 description="Pairing links and client-session management require the access:write scope for this backend."4061 description="Connect an environment to view its settings and access."
3904 />4062 />
3905 <CloudLinkRow canManageRelay={canManageRelay} />4063 {canReadRelay || canManageRelay ? (
4064 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
4065 ) : null}
3906 </SettingsSection>4066 </SettingsSection>
3907 )}4067 )}
3908 </>4068 </>
apps/web/src/components/settings/DeviceHostsSettings.tsx+10-2
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { DeviceToolVersions } from "../device/DeviceToolVersions";1import { DeviceToolVersions } from "../device/DeviceToolVersions";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";4import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
3import { AppleIcon, AndroidIcon } from "../Icons";5import { AppleIcon, AndroidIcon } from "../Icons";
4import { Spinner } from "../ui/spinner";6import { Spinner } from "../ui/spinner";
...@@ -22,6 +24,7 @@ import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";...@@ -22,6 +24,7 @@ import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";
2224
23export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {25export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
24 const { scope, environments, connectedEnvironments } = useSettingsScope();26 const { scope, environments, connectedEnvironments } = useSettingsScope();
27 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
25 const projectScope = scope.kind === "project" || scope.kind === "checkout";28 const projectScope = scope.kind === "project" || scope.kind === "checkout";
26 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });29 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
27 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);30 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
...@@ -42,6 +45,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -42,6 +45,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
42 if (environment.connection.phase !== "connected" || !environment.serverConfig) {45 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
43 throw new Error("Environment disconnected");46 throw new Error("Environment disconnected");
44 }47 }
48 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
49 throw new Error("This connection cannot change device settings.");
50 }
45 return update({51 return update({
46 environmentId: environment.environmentId,52 environmentId: environment.environmentId,
47 input: {53 input: {
...@@ -85,7 +91,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -85,7 +91,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
85 <Button91 <Button
86 size="sm"92 size="sm"
87 variant="outline"93 variant="outline"
88 disabled={projectScope || busy || !props.environmentId || editing !== null}94 disabled={
95 !canConfigure || projectScope || busy || !props.environmentId || editing !== null
96 }
89 onClick={() => {97 onClick={() => {
90 setOriginalHost(null);98 setOriginalHost(null);
91 setEditing({ id: randomUUID(), label: "", target: "" });99 setEditing({ id: randomUUID(), label: "", target: "" });
...@@ -113,7 +121,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -113,7 +121,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
113 environmentLabel={environment.label}121 environmentLabel={environment.label}
114 environmentId={environment.environmentId}122 environmentId={environment.environmentId}
115 hosts={environment.serverConfig?.settings.deviceHosts ?? []}123 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
116 busy={projectScope || busy}124 busy={!canConfigure || projectScope || busy}
117 checks={checks}125 checks={checks}
118 testConnection={async (host) => {126 testConnection={async (host) => {
119 const results = await testConnection(host);127 const results = await testConnection(host);
apps/web/src/components/settings/DiagnosticsSettings.tsx+34-23
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { ProcessSignalActions } from "./ProcessSignalActions";1import { ProcessSignalActions } from "./ProcessSignalActions";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
4import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
2import { RefreshIcon } from "~/components/ui/refresh-icon";5import { RefreshIcon } from "~/components/ui/refresh-icon";
3import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon } from "lucide-react";6import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon } from "lucide-react";
4import { ChevronDown, ChevronRight } from "lucide";7import { ChevronDown, ChevronRight } from "lucide";
...@@ -17,7 +20,7 @@ import * as Option from "effect/Option";...@@ -17,7 +20,7 @@ import * as Option from "effect/Option";
1720
18import { cn } from "../../lib/utils";21import { cn } from "../../lib/utils";
19import { ensureLocalApi } from "../../localApi";22import { ensureLocalApi } from "../../localApi";
20import { resolveAndPersistPreferredEditor } from "../../editorPreferences";23import { useOpenInPreferredEditor } from "../../editorPreferences";
21import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";24import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
22import { useEnvironmentQuery } from "../../state/query";25import { useEnvironmentQuery } from "../../state/query";
23import { serverEnvironment } from "../../state/server";26import { serverEnvironment } from "../../state/server";
...@@ -296,11 +299,13 @@ function ProcessNameCell({...@@ -296,11 +299,13 @@ function ProcessNameCell({
296299
297function ProcessDiagnosticsTable({300function ProcessDiagnosticsTable({
298 processes,301 processes,
302 canMaintainEnvironment,
299 signalingPid,303 signalingPid,
300 onSignal,304 onSignal,
301 emptyLabel,305 emptyLabel,
302}: {306}: {
303 processes: ReadonlyArray<ServerProcessDiagnosticsEntry>;307 processes: ReadonlyArray<ServerProcessDiagnosticsEntry>;
308 canMaintainEnvironment: boolean;
304 signalingPid: number | null;309 signalingPid: number | null;
305 onSignal: (pid: number, signal: ServerProcessSignal) => void;310 onSignal: (pid: number, signal: ServerProcessSignal) => void;
306 emptyLabel?: string;311 emptyLabel?: string;
...@@ -408,7 +413,7 @@ function ProcessDiagnosticsTable({...@@ -408,7 +413,7 @@ function ProcessDiagnosticsTable({
408 </td>413 </td>
409 <td className="p-2 align-middle sm:pr-4">414 <td className="p-2 align-middle sm:pr-4">
410 <ProcessSignalActions415 <ProcessSignalActions
411 disabled={signalingPid === process.pid}416 disabled={!canMaintainEnvironment || signalingPid === process.pid}
412 onSignal={(signal) => onSignal(process.pid, signal)}417 onSignal={(signal) => onSignal(process.pid, signal)}
413 />418 />
414 </td>419 </td>
...@@ -710,14 +715,14 @@ export function DiagnosticsSettingsPanel() {...@@ -710,14 +715,14 @@ export function DiagnosticsSettingsPanel() {
710 // The boundary only mounts this page when the selection resolves to one715 // The boundary only mounts this page when the selection resolves to one
711 // connected environment, so the representative is the one to inspect.716 // connected environment, so the representative is the one to inspect.
712 const environmentId = environment?.environmentId ?? null;717 const environmentId = environment?.environmentId ?? null;
718 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
713 const observability = environment?.serverConfig?.observability;719 const observability = environment?.serverConfig?.observability;
714 const availableEditors = environment?.serverConfig?.availableEditors;720 const availableEditors = environment?.serverConfig?.availableEditors;
721 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
715 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {722 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
716 reportFailure: false,723 reportFailure: false,
717 });724 });
718 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {725 const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors ?? []);
719 reportFailure: false,
720 });
721 const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);726 const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);
722 const selectedResourceWindow =727 const selectedResourceWindow =
723 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??728 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
...@@ -773,26 +778,17 @@ export function DiagnosticsSettingsPanel() {...@@ -773,26 +778,17 @@ export function DiagnosticsSettingsPanel() {
773 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;778 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
774 if (!logsDirectoryPath) return;779 if (!logsDirectoryPath) return;
775780
776 const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);781 if (
777 if (!editor) {782 environmentId === null ||
778 setOpenLogsDirectoryError("No available editors found.");783 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
779 return;784 ) {
780 }
781 if (environmentId === null) {
782 setOpenLogsDirectoryError("No environment is selected.");
783 return;785 return;
784 }786 }
785787
786 setIsOpeningLogsDirectory(true);788 setIsOpeningLogsDirectory(true);
787 setOpenLogsDirectoryError(null);789 setOpenLogsDirectoryError(null);
788 void (async () => {790 void (async () => {
789 const result = await openInEditor({791 const result = await openInEditor(logsDirectoryPath);
790 environmentId,
791 input: {
792 cwd: logsDirectoryPath,
793 editor,
794 },
795 });
796 setIsOpeningLogsDirectory(false);792 setIsOpeningLogsDirectory(false);
797 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {793 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
798 const error = squashAtomCommandFailure(result);794 const error = squashAtomCommandFailure(result);
...@@ -801,7 +797,7 @@ export function DiagnosticsSettingsPanel() {...@@ -801,7 +797,7 @@ export function DiagnosticsSettingsPanel() {
801 );797 );
802 }798 }
803 })();799 })();
804 }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]);800 }, [environmentId, observability?.logsDirectoryPath, openInEditor]);
805801
806 const isInitialLoading = isPending && data === null;802 const isInitialLoading = isPending && data === null;
807 const isProcessInitialLoading = isProcessPending && processData === null;803 const isProcessInitialLoading = isProcessPending && processData === null;
...@@ -809,7 +805,12 @@ export function DiagnosticsSettingsPanel() {...@@ -809,7 +805,12 @@ export function DiagnosticsSettingsPanel() {
809 async (pid: number, signal: ServerProcessSignal) => {805 async (pid: number, signal: ServerProcessSignal) => {
810 const targetEnvironmentId = environmentIdRef.current;806 const targetEnvironmentId = environmentIdRef.current;
811 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);807 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);
812 if (targetEnvironmentId === null || process === undefined) return;808 if (
809 targetEnvironmentId === null ||
810 process === undefined ||
811 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
812 )
813 return;
813 if (signalingPidRef.current !== null) return;814 if (signalingPidRef.current !== null) return;
814 signalingPidRef.current = pid;815 signalingPidRef.current = pid;
815 setSignalingPid(pid);816 setSignalingPid(pid);
...@@ -838,7 +839,12 @@ export function DiagnosticsSettingsPanel() {...@@ -838,7 +839,12 @@ export function DiagnosticsSettingsPanel() {
838 return;839 return;
839 }840 }
840 }841 }
841 if (environmentIdRef.current !== targetEnvironmentId) {842 const currentEnvironmentId = environmentIdRef.current;
843 if (
844 currentEnvironmentId === null ||
845 currentEnvironmentId !== targetEnvironmentId ||
846 !readEnvironmentScope(currentEnvironmentId, AuthEnvironmentMaintainScope)
847 ) {
842 clearSignaling();848 clearSignaling();
843 return;849 return;
844 }850 }
...@@ -956,6 +962,7 @@ export function DiagnosticsSettingsPanel() {...@@ -956,6 +962,7 @@ export function DiagnosticsSettingsPanel() {
956 ) : null}962 ) : null}
957 <ProcessDiagnosticsTable963 <ProcessDiagnosticsTable
958 processes={processData?.processes ?? []}964 processes={processData?.processes ?? []}
965 canMaintainEnvironment={canMaintainEnvironment}
959 signalingPid={signalingPid}966 signalingPid={signalingPid}
960 onSignal={signalProcess}967 onSignal={signalProcess}
961 emptyLabel={968 emptyLabel={
...@@ -1041,7 +1048,11 @@ export function DiagnosticsSettingsPanel() {...@@ -1041,7 +1048,11 @@ export function DiagnosticsSettingsPanel() {
1041 <Button1048 <Button
1042 size="icon-xs"1049 size="icon-xs"
1043 variant="ghost-muted"1050 variant="ghost-muted"
1044 disabled={!observability?.logsDirectoryPath || isOpeningLogsDirectory}1051 disabled={
1052 !canOpenHostEditor ||
1053 !observability?.logsDirectoryPath ||
1054 isOpeningLogsDirectory
1055 }
1045 onClick={openLogsDirectory}1056 onClick={openLogsDirectory}
1046 aria-label="Open logs folder"1057 aria-label="Open logs folder"
1047 >1058 >
apps/web/src/components/settings/EnvironmentIconPicker.test.ts+2-2
...@@ -30,10 +30,10 @@ describe("resolveEnvironmentIconPickerLock", () => {...@@ -30,10 +30,10 @@ describe("resolveEnvironmentIconPickerLock", () => {
30 ).toMatch(/cannot change/);30 ).toMatch(/cannot change/);
31 });31 });
3232
33 it("stays open while access is still resolving so a slow session does not flicker", () => {33 it("waits for a settings grant before allowing changes", () => {
34 expect(34 expect(
35 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }),35 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }),
36 ).toBeNull();36 ).toMatch(/cannot change/);
37 expect(37 expect(
38 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }),38 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }),
39 ).toBeNull();39 ).toBeNull();
apps/web/src/components/settings/EnvironmentIconPicker.tsx+6-35
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 AuthSettingsWriteScope,
2 ENVIRONMENT_MACHINE_KINDS,3 ENVIRONMENT_MACHINE_KINDS,
3 isEnvironmentMachineKind,4 isEnvironmentMachineKind,
4 resolveEnvironmentMachineKind,5 resolveEnvironmentMachineKind,
...@@ -6,11 +7,8 @@ import {...@@ -6,11 +7,8 @@ import {
6 type ServerConfig,7 type ServerConfig,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
89
9import { isElectron } from "../../env";
10import { usePrimarySessionState } from "../../environments/primary";
11import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";10import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
12import { usePrimaryEnvironmentId } from "../../state/environments";11import { useEnvironmentScope } from "../../state/session";
13import { useEnvironmentSessionState } from "../../state/session";
14import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";12import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
15import {13import {
16 MenuItem,14 MenuItem,
...@@ -21,10 +19,6 @@ import {...@@ -21,10 +19,6 @@ import {
21 MenuSubPopup,19 MenuSubPopup,
22 MenuSubTrigger,20 MenuSubTrigger,
23} from "../ui/menu";21} from "../ui/menu";
24import {
25 resolvePrimaryOperateAccess,
26 resolveRemoteOperateAccess,
27} from "./ProviderSettingsPanel.logic";
2822
29/**23/**
30 * Why the picker is inert, in the order the user can do something about it.24 * Why the picker is inert, in the order the user can do something about it.
...@@ -40,37 +34,12 @@ export function resolveEnvironmentIconPickerLock(input: {...@@ -40,37 +34,12 @@ export function resolveEnvironmentIconPickerLock(input: {
40 if (input.serverConfig.environment.capabilities.environmentIcon !== true) {34 if (input.serverConfig.environment.capabilities.environmentIcon !== true) {
41 return "This environment's server is too old to keep an icon. Update it to choose one.";35 return "This environment's server is too old to keep an icon. Update it to choose one.";
42 }36 }
43 if (input.operateAccess === "denied") {37 if (input.operateAccess !== "granted") {
44 return "Your session on this environment cannot change its settings.";38 return "Your session on this environment cannot change its settings.";
45 }39 }
46 return null;40 return null;
47}41}
4842
49// Same split the provider settings use: the desktop app owns its primary
50// server outright, a browser session on the primary checks its cookie
51// session's scopes, and a remote checks the scopes its own server reports.
52function useEnvironmentOperateAccess(environmentId: EnvironmentId) {
53 const isPrimary = usePrimaryEnvironmentId() === environmentId;
54 const primarySession = usePrimarySessionState();
55 const remoteSession = useEnvironmentSessionState(environmentId);
56 if (isPrimary) {
57 return isElectron
58 ? "granted"
59 : resolvePrimaryOperateAccess({
60 isPrimary: true,
61 hasDesktopBridge: false,
62 session: primarySession.data,
63 isPending: primarySession.isPending,
64 hasError: primarySession.error !== null,
65 });
66 }
67 return resolveRemoteOperateAccess({
68 session: remoteSession.data,
69 isPending: remoteSession.isPending,
70 hasError: remoteSession.hasError,
71 });
72}
73
74/**43/**
75 * "Icon" submenu for an environment's row menu. Lists the machine kinds with44 * "Icon" submenu for an environment's row menu. Lists the machine kinds with
76 * the server's own detection marked, so the user can tell whether detection45 * the server's own detection marked, so the user can tell whether detection
...@@ -86,7 +55,9 @@ export function EnvironmentIconMenu({...@@ -86,7 +55,9 @@ export function EnvironmentIconMenu({
86 readonly serverConfig: ServerConfig | null;55 readonly serverConfig: ServerConfig | null;
87}) {56}) {
88 const updateSettings = useUpdateEnvironmentSettings(environmentId);57 const updateSettings = useUpdateEnvironmentSettings(environmentId);
89 const operateAccess = useEnvironmentOperateAccess(environmentId);58 const operateAccess = useEnvironmentScope(environmentId, AuthSettingsWriteScope)
59 ? "granted"
60 : "denied";
90 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });61 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });
91 // With no detection the server falls back to "server", so picking that62 // With no detection the server falls back to "server", so picking that
92 // kind clears the override the same way picking the detected kind does.63 // kind clears the override the same way picking the detected kind does.
apps/web/src/components/settings/IntegrationsSettings.test.tsx+12
...@@ -17,6 +17,16 @@ const { listBrowserImportSources } = vi.hoisted(() => ({...@@ -17,6 +17,16 @@ const { listBrowserImportSources } = vi.hoisted(() => ({
17 listBrowserImportSources: vi.fn().mockResolvedValue([]),17 listBrowserImportSources: vi.fn().mockResolvedValue([]),
18}));18}));
1919
20vi.mock("../ui/tooltip", () => ({
21 Tooltip: ({ children }: { children: ReactNode }) => children,
22 TooltipTrigger: ({ render, children }: { render: ReactNode; children: ReactNode }) => (
23 <>
24 {render}
25 {children}
26 </>
27 ),
28 TooltipPopup: () => null,
29}));
20vi.mock("../preview/previewBridge", () => ({30vi.mock("../preview/previewBridge", () => ({
21 previewBridge: { listBrowserImportSources },31 previewBridge: { listBrowserImportSources },
22}));32}));
...@@ -24,6 +34,8 @@ vi.mock("../../env", () => ({ isElectron: true }));...@@ -24,6 +34,8 @@ vi.mock("../../env", () => ({ isElectron: true }));
24vi.mock("../../state/environments", () => ({34vi.mock("../../state/environments", () => ({
25 useEnvironments: () => ({ environments: [], isReady: true }),35 useEnvironments: () => ({ environments: [], isReady: true }),
26 usePrimaryEnvironment: () => null,36 usePrimaryEnvironment: () => null,
37 // Settings rows resolve the primary grant before rendering server controls.
38 usePrimaryEnvironmentId: () => null,
27}));39}));
28vi.mock("../../hooks/useSettings", async (importOriginal) => ({40vi.mock("../../hooks/useSettings", async (importOriginal) => ({
29 ...(await importOriginal<typeof import("../../hooks/useSettings")>()),41 ...(await importOriginal<typeof import("../../hooks/useSettings")>()),
apps/web/src/components/settings/IntegrationsSettings.tsx+16-2
...@@ -12,6 +12,7 @@ import { DeviceHostsSettings } from "./DeviceHostsSettings";...@@ -12,6 +12,7 @@ import { DeviceHostsSettings } from "./DeviceHostsSettings";
12 * @module IntegrationsSettings12 * @module IntegrationsSettings
13 */13 */
14import {14import {
15 AuthSettingsWriteScope,
15 BrowserImportFailureReason,16 BrowserImportFailureReason,
16 BROWSER_PROFILE_MAX_COUNT,17 BROWSER_PROFILE_MAX_COUNT,
17 type BrowserLinkTarget,18 type BrowserLinkTarget,
...@@ -49,6 +50,7 @@ import { previewBridge } from "~/components/preview/previewBridge";...@@ -49,6 +50,7 @@ import { previewBridge } from "~/components/preview/previewBridge";
49import { cn, randomUUID } from "~/lib/utils";50import { cn, randomUUID } from "~/lib/utils";
50import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";51import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
51import { deviceEnvironment, useDeviceState } from "~/state/device";52import { deviceEnvironment, useDeviceState } from "~/state/device";
53import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
52import { useAtomCommand } from "~/state/use-atom-command";54import { useAtomCommand } from "~/state/use-atom-command";
53import { previewEnvironment } from "~/state/preview";55import { previewEnvironment } from "~/state/preview";
54import { useServerConfigs } from "~/state/entities";56import { useServerConfigs } from "~/state/entities";
...@@ -647,6 +649,7 @@ function DeviceIntegrationControls({...@@ -647,6 +649,7 @@ function DeviceIntegrationControls({
647 enabled: boolean;649 enabled: boolean;
648 agentAccessEnabled: boolean;650 agentAccessEnabled: boolean;
649}) {651}) {
652 const canConfigure = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
650 const { state, loaded } = useDeviceState(environmentId);653 const { state, loaded } = useDeviceState(environmentId);
651 const { scope, environments, connectedEnvironments } = useSettingsScope();654 const { scope, environments, connectedEnvironments } = useSettingsScope();
652 const updateSettings = useUpdateScopedSettings();655 const updateSettings = useUpdateScopedSettings();
...@@ -679,7 +682,7 @@ function DeviceIntegrationControls({...@@ -679,7 +682,7 @@ function DeviceIntegrationControls({
679 kind: NonNullable<typeof pending>,682 kind: NonNullable<typeof pending>,
680 input: { enabled?: boolean; agentAccessEnabled?: boolean },683 input: { enabled?: boolean; agentAccessEnabled?: boolean },
681 ) => {684 ) => {
682 if (!environmentId) return;685 if (!environmentId || !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
683 setPending(kind);686 setPending(kind);
684 try {687 try {
685 const results = await Promise.allSettled(688 const results = await Promise.allSettled(
...@@ -687,6 +690,9 @@ function DeviceIntegrationControls({...@@ -687,6 +690,9 @@ function DeviceIntegrationControls({
687 if (environment.connection.phase !== "connected" || !environment.serverConfig) {690 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
688 throw new Error("Environment disconnected");691 throw new Error("Environment disconnected");
689 }692 }
693 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
694 throw new Error("This connection cannot change device settings.");
695 }
690 return configure({696 return configure({
691 environmentId: environment.environmentId,697 environmentId: environment.environmentId,
692 input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) },698 input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) },
...@@ -786,7 +792,14 @@ function DeviceIntegrationControls({...@@ -786,7 +792,14 @@ function DeviceIntegrationControls({
786 <ScopedSwitch792 <ScopedSwitch
787 settingKeys={["enableDeviceSupport"]}793 settingKeys={["enableDeviceSupport"]}
788 checked={enabled}794 checked={enabled}
789 disabled={projectScope || !loaded || !environmentId || busy || pending !== null}795 disabled={
796 !canConfigure ||
797 projectScope ||
798 !loaded ||
799 !environmentId ||
800 busy ||
801 pending !== null
802 }
790 aria-label="Device hub"803 aria-label="Device hub"
791 onCheckedChange={(checked) =>804 onCheckedChange={(checked) =>
792 void update("hub", {805 void update("hub", {
...@@ -851,6 +864,7 @@ function DeviceIntegrationControls({...@@ -851,6 +864,7 @@ function DeviceIntegrationControls({
851 settingKeys={["enableAgentDeviceAccess"]}864 settingKeys={["enableAgentDeviceAccess"]}
852 checked={agentAccessEnabled}865 checked={agentAccessEnabled}
853 disabled={866 disabled={
867 !canConfigure ||
854 connectedEnvironments.length === 0 ||868 connectedEnvironments.length === 0 ||
855 (!projectScope && (!loaded || !anyHubEnabled || busy)) ||869 (!projectScope && (!loaded || !anyHubEnabled || busy)) ||
856 pending !== null870 pending !== null
apps/web/src/components/settings/KeybindingsSettings.environment.test.tsx created+143
...@@ -0,0 +1,143 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { visitElements } from "../../test/reactElementTree";
5import { reactHookHarness as hooks } from "../../test/reactHookHarness";
6
7const state = vi.hoisted(() => ({
8 canOperate: false,
9 canWriteSettings: true,
10 openEditor: vi.fn(),
11 upsert: vi.fn(),
12 remove: vi.fn(),
13}));
14
15vi.mock("react", async (importOriginal) => {
16 const actual = await importOriginal<typeof import("react")>();
17 const { reactHookHarness } = await import("../../test/reactHookHarness");
18 return {
19 ...actual,
20 useCallback: reactHookHarness.useCallback,
21 useEffect: () => undefined,
22 useMemo: reactHookHarness.useMemo,
23 useRef: reactHookHarness.useRef,
24 useState: reactHookHarness.useState,
25 };
26});
27
28vi.mock("react/compiler-runtime", async () => {
29 const { reactHookHarness } = await import("../../test/reactHookHarness");
30 return { c: reactHookHarness.useMemoCache };
31});
32
33vi.mock("@effect/atom-react", () => ({
34 useAtomValue: (atom: string) => (atom === "config-path" ? "/fixture/keybindings.json" : []),
35}));
36
37vi.mock("@tanstack/react-router", () => ({ useLocation: () => "" }));
38vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null }));
39vi.mock("../../state/server", () => ({
40 primaryServerKeybindingsAtom: "keybindings",
41 primaryServerKeybindingsConfigPathAtom: "config-path",
42 primaryServerAvailableEditorsAtom: "editors",
43 serverEnvironment: { upsertKeybinding: state.upsert, removeKeybinding: state.remove },
44}));
45
46vi.mock("../../state/environments", () => ({
47 usePrimaryEnvironment: () => ({ environmentId: "primary-settings" }),
48}));
49
50vi.mock("./SettingsScopeContext", () => ({
51 useSettingsScope: () => {
52 const environment = {
53 environmentId: "primary-settings",
54 serverConfig: {
55 keybindings: [],
56 keybindingsConfigPath: "/fixture/keybindings.json",
57 availableEditors: [],
58 },
59 };
60 return { environment, connectedEnvironments: [environment] };
61 },
62}));
63
64vi.mock("../../state/session", () => {
65 const hasScope = (environmentId: EnvironmentId | null, scope: string) =>
66 environmentId === "primary-settings" &&
67 (scope === "orchestration:operate"
68 ? state.canOperate
69 : scope === "settings:write" && state.canWriteSettings);
70 return {
71 useEnvironmentScope: hasScope,
72 readEnvironmentScope: hasScope,
73 useEnvironmentsWithScope: () => new Set(state.canWriteSettings ? ["primary-settings"] : []),
74 };
75});
76
77vi.mock("../../state/use-atom-command", () => ({
78 useAtomCommand: (command: unknown) => command,
79}));
80
81vi.mock("../../editorPreferences", () => ({
82 useOpenInPreferredEditor: (environmentId: EnvironmentId) => (path: string) =>
83 state.openEditor({ environmentId, path }),
84}));
85
86import { KeybindingsSettingsPanel } from "./KeybindingsSettings";
87
88function renderPanel() {
89 hooks.beginRender();
90 return KeybindingsSettingsPanel();
91}
92
93function openButton(panel: unknown) {
94 const button = visitElements(
95 panel,
96 (element) => element.props["aria-label"] === "Open keybindings.json",
97 );
98 if (!button) throw new Error("Missing Open keybindings.json action.");
99 return button;
100}
101
102describe("KeybindingsSettings editor permission", () => {
103 beforeEach(() => {
104 hooks.reset();
105 state.canOperate = false;
106 state.canWriteSettings = true;
107 state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
108 state.upsert.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
109 state.remove.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
110 });
111
112 it("rejects editor launches when settings writes are the only write grant", () => {
113 const button = openButton(renderPanel());
114 (button.props.onClick as () => void)();
115
116 expect(state.openEditor).not.toHaveBeenCalled();
117 expect(button.props.disabled).toBe(true);
118 });
119
120 it("opens the primary environment's file after operate is granted without settings writes", () => {
121 state.canWriteSettings = false;
122 renderPanel();
123 state.canOperate = true;
124 const button = openButton(renderPanel());
125 expect(button.props.disabled).toBe(false);
126 (button.props.onClick as () => void)();
127
128 expect(state.openEditor).toHaveBeenCalledWith({
129 environmentId: "primary-settings",
130 path: "/fixture/keybindings.json",
131 });
132 });
133
134 it("rejects a queued editor launch after operate is revoked", () => {
135 state.canOperate = true;
136 const open = openButton(renderPanel()).props.onClick as () => void;
137 state.canOperate = false;
138 open();
139
140 expect(state.openEditor).not.toHaveBeenCalled();
141 expect(openButton(renderPanel()).props.disabled).toBe(true);
142 });
143});
apps/web/src/components/settings/KeybindingsSettings.tsx+67-31
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useEnvironmentScope } from "../../state/session";
2import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
1import {3import {
2 ChevronDownIcon,4 ChevronDownIcon,
3 CircleXIcon,5 CircleXIcon,
...@@ -22,6 +24,8 @@ import {...@@ -22,6 +24,8 @@ import {
22 useState,24 useState,
23} from "react";25} from "react";
24import {26import {
27 AuthOrchestrationOperateScope,
28 AuthSettingsWriteScope,
25 type KeybindingCommand,29 type KeybindingCommand,
26 type KeybindingWhenNode,30 type KeybindingWhenNode,
27 type ServerRemoveKeybindingInput,31 type ServerRemoveKeybindingInput,
...@@ -1281,6 +1285,14 @@ export function KeybindingsSettingsPanel() {...@@ -1281,6 +1285,14 @@ export function KeybindingsSettingsPanel() {
1281 // fan out to every connected environment in the selection, so one1285 // fan out to every connected environment in the selection, so one
1282 // shortcut change reaches each machine the user runs T3 Code on.1286 // shortcut change reaches each machine the user runs T3 Code on.
1283 const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();1287 const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();
1288 const canOpenKeybindingsFile = useEnvironmentScope(
1289 primaryEnvironment?.environmentId ?? null,
1290 AuthOrchestrationOperateScope,
1291 );
1292 const writableIds = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
1293 const canWriteSettings =
1294 connectedEnvironments.length > 0 &&
1295 connectedEnvironments.every((target) => writableIds.has(target.environmentId));
1284 const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings;1296 const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings;
1285 const keybindings = useMemo(1297 const keybindings = useMemo(
1286 () => mergeWithDefaultKeybindings(serverKeybindings ?? []),1298 () => mergeWithDefaultKeybindings(serverKeybindings ?? []),
...@@ -1351,7 +1363,12 @@ export function KeybindingsSettingsPanel() {...@@ -1351,7 +1363,12 @@ export function KeybindingsSettingsPanel() {
1351 }, []);1363 }, []);
13521364
1353 const openKeybindingsFile = useCallback(() => {1365 const openKeybindingsFile = useCallback(() => {
1354 if (!keybindingsConfigPath) return;1366 if (
1367 !keybindingsConfigPath ||
1368 !primaryEnvironment ||
1369 !readEnvironmentScope(primaryEnvironment.environmentId, AuthOrchestrationOperateScope)
1370 )
1371 return;
1355 void (async () => {1372 void (async () => {
1356 const result = await openInPreferredEditor(keybindingsConfigPath);1373 const result = await openInPreferredEditor(keybindingsConfigPath);
1357 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {1374 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
...@@ -1365,11 +1382,17 @@ export function KeybindingsSettingsPanel() {...@@ -1365,11 +1382,17 @@ export function KeybindingsSettingsPanel() {
1365 type: "error",1382 type: "error",
1366 });1383 });
1367 })();1384 })();
1368 }, [keybindingsConfigPath, openInPreferredEditor]);1385 }, [keybindingsConfigPath, openInPreferredEditor, primaryEnvironment]);
13691386
1370 const saveKeybinding = useCallback(1387 const saveKeybinding = useCallback(
1371 (input: ServerUpsertKeybindingInput) => {1388 (input: ServerUpsertKeybindingInput) => {
1372 if (!primaryEnvironment) return;1389 if (
1390 !primaryEnvironment ||
1391 !connectedEnvironments.every((target) =>
1392 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1393 )
1394 )
1395 return;
1373 setSavingCommand(input.command);1396 setSavingCommand(input.command);
1374 const payload: ServerUpsertKeybindingInput = {1397 const payload: ServerUpsertKeybindingInput = {
1375 command: input.command,1398 command: input.command,
...@@ -1404,7 +1427,13 @@ export function KeybindingsSettingsPanel() {...@@ -1404,7 +1427,13 @@ export function KeybindingsSettingsPanel() {
14041427
1405 const removeKeybinding = useCallback(1428 const removeKeybinding = useCallback(
1406 (row: KeybindingRow) => {1429 (row: KeybindingRow) => {
1407 if (!primaryEnvironment) return;1430 if (
1431 !primaryEnvironment ||
1432 !connectedEnvironments.every((target) =>
1433 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1434 )
1435 )
1436 return;
1408 setSavingCommand(row.command);1437 setSavingCommand(row.command);
1409 void (async () => {1438 void (async () => {
1410 const results = await Promise.all(1439 const results = await Promise.all(
...@@ -1468,7 +1497,7 @@ export function KeybindingsSettingsPanel() {...@@ -1468,7 +1497,7 @@ export function KeybindingsSettingsPanel() {
1468 <Button1497 <Button
1469 type="button"1498 type="button"
1470 variant="outline"1499 variant="outline"
1471 disabled={isAddingBinding}1500 disabled={isAddingBinding || !canWriteSettings}
1472 onClick={() => setIsAddingBinding(true)}1501 onClick={() => setIsAddingBinding(true)}
1473 >1502 >
1474 <PlusIcon aria-hidden className="size-4" />1503 <PlusIcon aria-hidden className="size-4" />
...@@ -1481,7 +1510,7 @@ export function KeybindingsSettingsPanel() {...@@ -1481,7 +1510,7 @@ export function KeybindingsSettingsPanel() {
1481 type="button"1510 type="button"
1482 variant="outline"1511 variant="outline"
1483 size="icon"1512 size="icon"
1484 disabled={!keybindingsConfigPath}1513 disabled={!keybindingsConfigPath || !canOpenKeybindingsFile}
1485 onClick={openKeybindingsFile}1514 onClick={openKeybindingsFile}
1486 aria-label="Open keybindings.json"1515 aria-label="Open keybindings.json"
1487 >1516 >
...@@ -1494,33 +1523,40 @@ export function KeybindingsSettingsPanel() {...@@ -1494,33 +1523,40 @@ export function KeybindingsSettingsPanel() {
1494 </div>1523 </div>
1495 </SettingsSection>1524 </SettingsSection>
14961525
1497 {isAddingBinding ? (1526 {!canWriteSettings ? (
1498 <SettingsGroup>1527 <p className="text-xs text-muted-foreground">
1499 <NewKeybindingSettingsRow1528 This connection can view keybindings but cannot change them.
1500 commandOptions={commandOptions}1529 </p>
1501 allRows={rows}
1502 variables={whenVariables}
1503 isSaving={savingCommand !== null}
1504 onSave={saveKeybinding}
1505 onCancel={cancelAdd}
1506 />
1507 </SettingsGroup>
1508 ) : null}1530 ) : null}
1531 <div inert={!canWriteSettings}>
1532 {isAddingBinding ? (
1533 <SettingsGroup>
1534 <NewKeybindingSettingsRow
1535 commandOptions={commandOptions}
1536 allRows={rows}
1537 variables={whenVariables}
1538 isSaving={savingCommand !== null}
1539 onSave={saveKeybinding}
1540 onCancel={cancelAdd}
1541 />
1542 </SettingsGroup>
1543 ) : null}
15091544
1510 {groups.length > 0 ? (1545 {groups.length > 0 ? (
1511 <KeybindingsGroups1546 <KeybindingsGroups
1512 groups={groups}1547 groups={groups}
1513 anchorIds={anchorIds}1548 anchorIds={anchorIds}
1514 savingCommand={savingCommand}1549 savingCommand={savingCommand}
1515 {...rowActions}1550 {...rowActions}
1516 />1551 />
1517 ) : (1552 ) : (
1518 <SettingsGroup>1553 <SettingsGroup>
1519 <div className="px-4 py-12 text-center text-sm text-muted-foreground">1554 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1520 No keybindings match your search.1555 No keybindings match your search.
1521 </div>1556 </div>
1522 </SettingsGroup>1557 </SettingsGroup>
1523 )}1558 )}
1559 </div>
1524 </SettingsPageContainer>1560 </SettingsPageContainer>
1525 );1561 );
1526}1562}
apps/web/src/components/settings/ProjectActionsSettings.tsx+19-12
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
1import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts";3import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts";
2import {4import {
3 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
...@@ -52,6 +54,7 @@ export function ProjectActionsSettings() {...@@ -52,6 +54,7 @@ export function ProjectActionsSettings() {
52 (candidate) =>54 (candidate) =>
53 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),55 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),
54 );56 );
57 const canWriteSettings = useScopedSettingsWriteAllowed();
55 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);58 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
56 const memberById = new Map(59 const memberById = new Map(
57 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],60 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],
...@@ -148,7 +151,7 @@ export function ProjectActionsSettings() {...@@ -148,7 +151,7 @@ export function ProjectActionsSettings() {
148 id="import-scripts"151 id="import-scripts"
149 size="xs"152 size="xs"
150 variant="ghost"153 variant="ghost"
151 disabled={saving}154 disabled={saving || !canWriteSettings}
152 type="button"155 type="button"
153 />156 />
154 }157 }
...@@ -184,7 +187,7 @@ export function ProjectActionsSettings() {...@@ -184,7 +187,7 @@ export function ProjectActionsSettings() {
184 <Button187 <Button
185 size="xs"188 size="xs"
186 variant="outline"189 variant="outline"
187 disabled={saving || targets.length === 0}190 disabled={saving || !canWriteSettings || targets.length === 0}
188 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}191 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
189 >192 >
190 <PlusIcon className="size-3.5" />193 <PlusIcon className="size-3.5" />
...@@ -202,7 +205,7 @@ export function ProjectActionsSettings() {...@@ -202,7 +205,7 @@ export function ProjectActionsSettings() {
202 <ProjectActionsList205 <ProjectActionsList
203 scripts={scripts}206 scripts={scripts}
204 keybindings={keybindings}207 keybindings={keybindings}
205 disabled={saving}208 disabled={saving || !canWriteSettings}
206 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}209 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
207 />210 />
208 )}211 )}
...@@ -213,15 +216,19 @@ export function ProjectActionsSettings() {...@@ -213,15 +216,19 @@ export function ProjectActionsSettings() {
213 className="text-warning"216 className="text-warning"
214 />217 />
215 ) : null}218 ) : null}
216 <ProjectScriptEditorDialog219 {target && (
217 request={request}220 <ProjectScriptEditorDialog
218 scripts={scripts}221 environmentId={target.environmentId}
219 onSubmit={submit}222 editScope={AuthSettingsWriteScope}
220 onDelete={(id) =>223 request={request}
221 void persist((current) => current.filter((script) => script.id !== id), id, null)224 scripts={scripts}
222 }225 onSubmit={submit}
223 onClose={() => setRequest(null)}226 onDelete={(id) =>
224 />227 void persist((current) => current.filter((script) => script.id !== id), id, null)
228 }
229 onClose={() => setRequest(null)}
230 />
231 )}
225 </SettingsSection>232 </SettingsSection>
226 );233 );
227}234}
apps/web/src/components/settings/ProjectSettingsPanel.test.tsx created+476
...@@ -0,0 +1,476 @@
1import { RegistryContext } from "@effect/atom-react";
2import type {
3 DeleteProjectInput,
4 UpdateProjectInput,
5} from "@t3tools/client-runtime/state/projects";
6import {
7 AuthOrchestrationOperateScope,
8 AuthSettingsWriteScope,
9 DEFAULT_SERVER_SETTINGS,
10 EnvironmentId,
11 ProjectId,
12 ProviderDriverKind,
13 ProviderInstanceId,
14 type AuthEnvironmentScope,
15 type AuthSessionState,
16 type ResolvedKeybindingsConfig,
17 type ServerProvider,
18} from "@t3tools/contracts";
19import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
20import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
21import * as Cause from "effect/Cause";
22import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
23import { cloneElement, type ReactElement, type ReactNode } from "react";
24import { act, create, type ReactTestRenderer } from "react-test-renderer";
25import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
26
27import type { Project } from "../../types";
28
29type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
30const state = vi.hoisted(() => ({
31 registry: null as AtomRegistry.AtomRegistry | null,
32 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
33 providers: null as Atom.Atom<ReadonlyArray<ServerProvider>> | null,
34 config: null as Atom.Atom<{ keybindings: ResolvedKeybindingsConfig }> | null,
35 projects: [] as Project[],
36 update:
37 vi.fn<
38 (request: {
39 environmentId: EnvironmentId;
40 input: UpdateProjectInput;
41 }) => Promise<AsyncResult.Success<void>>
42 >(),
43 delete:
44 vi.fn<
45 (request: {
46 environmentId: EnvironmentId;
47 input: DeleteProjectInput;
48 }) => Promise<AsyncResult.Success<void>>
49 >(),
50 confirm: vi.fn<() => Promise<boolean>>(),
51 updateSettings: vi.fn(),
52 upsertKeybinding: vi.fn(),
53 removeKeybinding: vi.fn(),
54 updateClientSettings: vi.fn(),
55 releaseUploads: vi.fn(),
56 clearDraft: vi.fn(),
57 copy: vi.fn(),
58 navigate: vi.fn(),
59}));
60
61vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined }));
62vi.mock("@t3tools/client-runtime/state/session", () => ({
63 createEnvironmentSessionAtoms: () => ({
64 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
65 }),
66}));
67vi.mock("~/rpc/atomRegistry", () => ({
68 get appAtomRegistry() {
69 return state.registry;
70 },
71}));
72vi.mock("~/state/environments", () => ({
73 useEnvironments: () => ({ environments }),
74 usePrimaryEnvironmentId: () => primaryId,
75}));
76vi.mock("~/state/entities", () => ({
77 useProjects: () => state.projects,
78 useThreadShells: () => [],
79}));
80vi.mock("~/state/projects", () => ({
81 projectEnvironment: { update: "update", delete: "delete" },
82}));
83vi.mock("~/state/server", () => ({
84 EMPTY_SERVER_PROVIDERS: [],
85 serverEnvironment: {
86 providersValueAtom: () => state.providers!,
87 configValueAtom: () => state.config!,
88 updateSettings: "updateSettings",
89 upsertKeybinding: "upsertKeybinding",
90 removeKeybinding: "removeKeybinding",
91 },
92}));
93vi.mock("~/state/use-atom-command", () => ({
94 useAtomCommand: (
95 command: "update" | "delete" | "updateSettings" | "upsertKeybinding" | "removeKeybinding",
96 ) => state[command],
97}));
98vi.mock("~/hooks/useSettings", () => ({
99 useClientSettings: () => DEFAULT_CLIENT_SETTINGS,
100 useUpdateClientSettings: () => state.updateClientSettings,
101 usePrimarySettings: () => DEFAULT_SERVER_SETTINGS,
102 useEnvironmentSettings: () => DEFAULT_SERVER_SETTINGS,
103}));
104vi.mock("~/hooks/useT3ProjectFileScripts", () => ({
105 useT3ProjectFileState: () => ({
106 status: "valid",
107 file: null,
108 scripts: [{ name: "Imported", command: "vp test" }],
109 }),
110}));
111vi.mock("~/hooks/useCopyToClipboard", () => ({
112 useCopyToClipboard: () => ({ copyToClipboard: state.copy }),
113}));
114vi.mock("~/localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: state.confirm } }) }));
115vi.mock("~/composerDraftStore", () => ({
116 useComposerDraftStore: {
117 getState: () => ({
118 getDraftThreadByProjectRef: () => null,
119 clearProjectDraftThreadId: state.clearDraft,
120 }),
121 },
122}));
123vi.mock("~/lib/composerDraftUploads", () => ({ releaseProjectDraftUploads: state.releaseUploads }));
124vi.mock("~/env", () => ({ isElectron: true }));
125vi.mock("@tanstack/react-router", () => ({
126 useNavigate: () => state.navigate,
127 useLocation: () => "/settings/projects",
128}));
129vi.mock("../ui/toast", () => ({
130 toastManager: { add: vi.fn() },
131 stackedThreadToast: (value: unknown) => value,
132}));
133vi.mock("../ui/button", () => ({ Button: "button" }));
134vi.mock("../ui/input", () => ({ Input: "input" }));
135vi.mock("../ui/switch", () => ({ Switch: "input" }));
136vi.mock("../ui/select", () => ({
137 Select: "select",
138 SelectItem: "option",
139 SelectPopup: "div",
140 SelectTrigger: "button",
141 SelectValue: "span",
142}));
143vi.mock("../ui/menu", () => ({
144 Menu: "div",
145 MenuGroup: "div",
146 MenuGroupLabel: "div",
147 MenuItem: "button",
148 MenuPopup: "div",
149 MenuSeparator: "hr",
150 MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
151 cloneElement(render, {}, children),
152}));
153vi.mock("../ui/tooltip", () => ({
154 Tooltip: "div",
155 TooltipPopup: "span",
156 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
157}));
158vi.mock("./settingsLayout", () => ({
159 SETTINGS_PICKER_TRIGGER_CLASSNAME: "",
160 SettingsPageContainer: "div",
161 SettingsSection: ({
162 children,
163 headerAction,
164 }: {
165 children: ReactNode;
166 headerAction?: ReactNode;
167 }) => (
168 <section>
169 {headerAction}
170 {children}
171 </section>
172 ),
173 SettingsRow: ({ control, resetAction }: { control?: ReactNode; resetAction?: ReactNode }) => (
174 <div>
175 {resetAction}
176 {control}
177 </div>
178 ),
179 SettingResetButton: ({
180 label,
181 ...props
182 }: {
183 label: string;
184 disabled?: boolean;
185 onClick: () => void;
186 }) => <button {...props} aria-label={`Reset ${label} to default`} />,
187}));
188vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null }));
189vi.mock("./ProjectActionsSettings", () => ({ ProjectActionsSettings: () => null }));
190vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null }));
191vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: "model-picker" }));
192vi.mock("../chat/TraitsPicker", () => ({ TraitsPicker: "traits-picker" }));
193vi.mock("./ProjectFaviconPickerDialog", () => ({
194 canPickExternalProjectFavicon: () => false,
195 ProjectFaviconPickerDialog: "favicon-picker",
196}));
197vi.mock("./ProjectIconPickerDialog", () => ({ ProjectIconPickerDialog: "icon-picker" }));
198vi.mock("../projectScriptEditor", async (importOriginal) => ({
199 ...(await importOriginal<typeof import("../projectScriptEditor")>()),
200 ProjectScriptEditorDialog: "script-editor",
201}));
202
203import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping";
204import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";
205import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
206
207const primaryId = EnvironmentId.make("primary");
208const remoteId = EnvironmentId.make("remote");
209const instanceId = ProviderInstanceId.make("codex");
210const environments = [primaryId, remoteId].map((environmentId) => ({
211 environmentId,
212 label: environmentId,
213 connection: { phase: "connected" },
214 serverConfig: { keybindings: DEFAULT_RESOLVED_KEYBINDINGS },
215}));
216const session = (scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState => ({
217 authenticated: true,
218 scopes,
219 auth: {
220 policy: "remote-reachable",
221 bootstrapMethods: ["one-time-token"],
222 sessionMethods: ["bearer-access-token"],
223 sessionCookieName: "t3_session",
224 },
225});
226const writable = () => AsyncResult.success(session([AuthOrchestrationOperateScope]));
227
228function deferred<T>() {
229 let resolve!: (value: T) => void;
230 const promise = new Promise<T>((complete) => {
231 resolve = complete;
232 });
233 return { promise, resolve };
234}
235
236let renderer: ReactTestRenderer | undefined;
237
238beforeEach(() => {
239 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
240 vi.clearAllMocks();
241 state.registry = AtomRegistry.make();
242 state.sessions.clear();
243 for (const id of [primaryId, remoteId])
244 state.sessions.set(id, Atom.make<SessionResult>(writable()).pipe(Atom.keepAlive));
245 state.projects = [primaryId, remoteId].map((environmentId) => ({
246 id: ProjectId.make(`project-${environmentId}`),
247 environmentId,
248 title: "Shared repo",
249 workspaceRoot: `/work/${environmentId}`,
250 repositoryIdentity: {
251 canonicalKey: "github.com/example/shared-repo",
252 locator: {
253 source: "git-remote",
254 remoteName: "origin",
255 remoteUrl: "https://github.com/example/shared-repo.git",
256 },
257 },
258 defaultModelSelection: { instanceId, model: "gpt-5-codex" },
259 createdAt: "2026-01-01T00:00:00.000Z",
260 updatedAt: "2026-01-01T00:00:00.000Z",
261 scripts: [],
262 }));
263 state.providers = Atom.make<ReadonlyArray<ServerProvider>>([
264 {
265 instanceId,
266 driver: ProviderDriverKind.make("codex"),
267 enabled: true,
268 installed: true,
269 version: null,
270 status: "ready",
271 auth: { status: "authenticated" },
272 checkedAt: "2026-01-01T00:00:00.000Z",
273 models: [],
274 slashCommands: [],
275 skills: [],
276 },
277 ]);
278 state.config = Atom.make({ keybindings: DEFAULT_RESOLVED_KEYBINDINGS });
279 state.update.mockImplementation(async ({ environmentId, input }) => {
280 const { projectId, ...updates } = input;
281 const patch = Object.fromEntries(
282 Object.entries(updates).filter(([, value]) => value !== undefined),
283 ) as Partial<Project>;
284 state.projects = state.projects.map((project) =>
285 project.environmentId === environmentId && project.id === projectId
286 ? { ...project, ...patch }
287 : project,
288 );
289 return AsyncResult.success(undefined);
290 });
291 state.delete.mockResolvedValue(AsyncResult.success(undefined));
292 state.updateSettings.mockResolvedValue(AsyncResult.success(undefined));
293 state.confirm.mockResolvedValue(true);
294});
295
296afterEach(async () => {
297 await act(() => renderer?.unmount());
298 renderer = undefined;
299 state.registry?.dispose();
300 vi.unstubAllGlobals();
301});
302
303function group() {
304 return buildSidebarProjectSnapshots({
305 projects: state.projects,
306 settings: DEFAULT_CLIENT_SETTINGS,
307 primaryEnvironmentId: primaryId,
308 resolveEnvironmentLabel: (id) => id,
309 })[0]!;
310}
311
312async function mountPanel() {
313 await act(() => {
314 renderer = create(
315 <RegistryContext.Provider value={state.registry!}>
316 <ProjectSettingsPanel projectKey={group().projectKey} />
317 </RegistryContext.Provider>,
318 );
319 });
320 return renderer!.root;
321}
322
323function button(label: string) {
324 return renderer!.root.findAllByType("button").find((node) => node.children.includes(label))!;
325}
326
327async function grant(id: EnvironmentId, result: SessionResult) {
328 await act(() => state.registry!.set(state.sessions.get(id)!, result));
329}
330
331describe("project settings permissions", () => {
332 it("preflights every member before any shared field update", async () => {
333 await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope])));
334 const root = await mountPanel();
335 const name = root.findByProps({ "aria-label": "Project name" });
336
337 await act(async () => {
338 name.props.onChange();
339 name.props.onBlur({ currentTarget: { value: "Renamed" } });
340 root.findByType(ProjectFaviconPickerDialog).props.onSelect("/work/icon.png");
341 });
342
343 expect(state.update).not.toHaveBeenCalled();
344 expect(state.projects.every((project) => project.title === "Shared repo")).toBe(true);
345 expect(name.props.disabled).toBe(true);
346 expect(button("Remove all entries").props.disabled).toBe(true);
347 });
348
349 it("updates every member when all have project access without settings access", async () => {
350 const root = await mountPanel();
351 await act(async () => {
352 const name = root.findByProps({ "aria-label": "Project name" });
353 name.props.onChange();
354 name.props.onBlur({ currentTarget: { value: "Renamed" } });
355 });
356 expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([
357 primaryId,
358 remoteId,
359 ]);
360 expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]);
361 });
362
363 it("stops before the next member if its grant is revoked during an earlier update", async () => {
364 const firstUpdate = deferred<AsyncResult.Success<void>>();
365 state.update.mockReturnValueOnce(firstUpdate.promise);
366 const root = await mountPanel();
367 await act(async () => {
368 const name = root.findByProps({ "aria-label": "Project name" });
369 name.props.onChange();
370 name.props.onBlur({ currentTarget: { value: "Renamed" } });
371 });
372 expect(state.update).toHaveBeenCalledOnce();
373
374 await grant(remoteId, AsyncResult.success(session([])));
375 await act(async () => {
376 firstUpdate.resolve(AsyncResult.success(undefined));
377 await firstUpdate.promise;
378 });
379 expect(state.update).toHaveBeenCalledOnce();
380 expect(state.update.mock.calls[0]![0].environmentId).toBe(primaryId);
381 });
382
383 it("disables removal for a checkout whose project grant is missing", async () => {
384 await grant(remoteId, AsyncResult.success(session([])));
385 const root = await mountPanel();
386 const denied = root.findByProps({ "aria-label": "Remove checkout /work/remote" });
387 expect(denied.props.disabled).toBe(true);
388 await act(async () => denied.props.onClick());
389 expect(state.confirm).not.toHaveBeenCalled();
390 expect(state.delete).not.toHaveBeenCalled();
391 expect(root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.disabled).toBe(
392 false,
393 );
394 });
395
396 it("allows removing a writable checkout while denying removal of the mixed group", async () => {
397 await grant(remoteId, AsyncResult.success(session([])));
398 await mountPanel();
399 await act(async () => button("Remove all entries").props.onClick());
400 expect(state.confirm).not.toHaveBeenCalled();
401 expect(state.delete).not.toHaveBeenCalled();
402 await act(async () =>
403 renderer!.root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.onClick(),
404 );
405 expect(state.delete).toHaveBeenCalledExactlyOnceWith({
406 environmentId: primaryId,
407 input: { projectId: state.projects[0]!.id, force: true },
408 });
409 expect(state.releaseUploads).toHaveBeenCalledOnce();
410 expect(state.clearDraft).toHaveBeenCalledOnce();
411 });
412
413 it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => {
414 const confirmed = deferred<boolean>();
415 state.confirm.mockReturnValueOnce(confirmed.promise);
416 await mountPanel();
417 await act(async () => button("Remove all entries").props.onClick());
418 expect(state.confirm).toHaveBeenCalledOnce();
419 await grant(remoteId, AsyncResult.success(session([])));
420 await act(async () => {
421 confirmed.resolve(true);
422 await confirmed.promise;
423 });
424 expect(state.delete).not.toHaveBeenCalled();
425 expect(state.releaseUploads).not.toHaveBeenCalled();
426 expect(state.clearDraft).not.toHaveBeenCalled();
427 });
428
429 it("reacts to another member's revoked and restored grant and guards a stale handler", async () => {
430 await grant(
431 primaryId,
432 AsyncResult.success(session([AuthOrchestrationOperateScope, AuthSettingsWriteScope])),
433 );
434 const root = await mountPanel();
435 const name = () => root.findByProps({ "aria-label": "Project name" });
436 const staleSave = name().props.onBlur;
437 await act(async () => {
438 state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([])));
439 name().props.onChange();
440 staleSave({ currentTarget: { value: "Renamed" } });
441 });
442 expect(state.update).not.toHaveBeenCalled();
443 expect(name().props.disabled).toBe(true);
444
445 await grant(remoteId, writable());
446 expect(name().props.disabled).toBe(false);
447 await act(async () => {
448 name().props.onChange();
449 name().props.onBlur({ currentTarget: { value: "Renamed" } });
450 });
451 expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]);
452 });
453
454 it.each([
455 ["loading", () => AsyncResult.initial<AuthSessionState, Error>(), false],
456 [
457 "failed",
458 () => AsyncResult.failure<AuthSessionState, Error>(Cause.fail(new Error("Disconnected"))),
459 false,
460 ],
461 ["refreshing", () => AsyncResult.waiting(writable()), true],
462 ] as const)(
463 "uses the current grant while a member's session is %s",
464 async (_state, result, allowed) => {
465 await grant(remoteId, result());
466 const root = await mountPanel();
467 const name = root.findByProps({ "aria-label": "Project name" });
468 expect(name.props.disabled).toBe(!allowed);
469 await act(async () => {
470 name.props.onChange();
471 name.props.onBlur({ currentTarget: { value: "Renamed" } });
472 });
473 expect(state.update).toHaveBeenCalledTimes(allowed ? 2 : 0);
474 },
475 );
476});
apps/web/src/components/settings/ProjectSettingsPanel.tsx+59-11
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import { useComposerMenuState } from "../chat/useComposerMenuState";
2import { useOrchestrationCommand } from "../../state/use-orchestration-command";
3import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
4import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
1import {5import {
2 isAtomCommandInterrupted,6 isAtomCommandInterrupted,
3 mapAtomCommandResult,7 mapAtomCommandResult,
...@@ -23,7 +27,6 @@ import {...@@ -23,7 +27,6 @@ import {
23import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";27import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
24import { useThreadShells } from "../../state/entities";28import { useThreadShells } from "../../state/entities";
25import { projectEnvironment } from "../../state/projects";29import { projectEnvironment } from "../../state/projects";
26import { useAtomCommand } from "../../state/use-atom-command";
27import { ProjectFavicon } from "../ProjectFavicon";30import { ProjectFavicon } from "../ProjectFavicon";
28import { Alert, AlertDescription } from "../ui/alert";31import { Alert, AlertDescription } from "../ui/alert";
29import { Button } from "../ui/button";32import { Button } from "../ui/button";
...@@ -171,13 +174,21 @@ function ProjectDetail({...@@ -171,13 +174,21 @@ function ProjectDetail({
171 () => new Map(environments.map((environment) => [environment.environmentId, environment])),174 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
172 [environments],175 [environments],
173 );176 );
177 const editableIds = useEnvironmentsWithScope(group.memberProjects, AuthOrchestrationOperateScope);
178 const canEditGroup = group.memberProjects.every((member) =>
179 editableIds.has(member.environmentId),
180 );
174 const representative =181 const representative =
175 group.memberProjects.find(182 group.memberProjects.find(
176 (member) => environmentById.get(member.environmentId)?.serverConfig != null,183 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
177 ) ?? group.memberProjects[0]!;184 ) ?? group.memberProjects[0]!;
178 const threads = useThreadShells();185 const threads = useThreadShells();
179 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });186 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
180 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });187 reportFailure: false,
188 });
189 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
190 reportFailure: false,
191 });
181 const projectNameEditedRef = useRef(false);192 const projectNameEditedRef = useRef(false);
182193
183 const faviconPath = representative.faviconPath ?? null;194 const faviconPath = representative.faviconPath ?? null;
...@@ -204,6 +215,25 @@ function ProjectDetail({...@@ -204,6 +215,25 @@ function ProjectDetail({
204 );215 );
205 }, []);216 }, []);
206217
218 const checkProjectAccess = useCallback(
219 (members: ReadonlyArray<SidebarProjectGroupMember>, failureTitle: string) => {
220 const denied = members.find(
221 (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
222 );
223 if (!denied) return null;
224 const result = AsyncResult.failure<void, Error>(
225 Cause.fail(
226 new Error(
227 `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`,
228 ),
229 ),
230 );
231 reportFailure(failureTitle, result);
232 return result;
233 },
234 [reportFailure],
235 );
236
207 // Group-shared fields live on each physical project record, so a237 // Group-shared fields live on each physical project record, so a
208 // group-level edit fans out to every member.238 // group-level edit fans out to every member.
209 const updateAllMembers = useCallback(239 const updateAllMembers = useCallback(
...@@ -215,6 +245,8 @@ function ProjectDetail({...@@ -215,6 +245,8 @@ function ProjectDetail({
215 }>,245 }>,
216 failureTitle: string,246 failureTitle: string,
217 ): Promise<AtomCommandResult<void, unknown>> => {247 ): Promise<AtomCommandResult<void, unknown>> => {
248 const denied = checkProjectAccess(group.memberProjects, failureTitle);
249 if (denied) return denied;
218 const unavailable = group.memberProjects.find((member) => {250 const unavailable = group.memberProjects.find((member) => {
219 const environment = environmentById.get(member.environmentId);251 const environment = environmentById.get(member.environmentId);
220 return environment?.connection.phase !== "connected" || !environment.serverConfig;252 return environment?.connection.phase !== "connected" || !environment.serverConfig;
...@@ -228,6 +260,8 @@ function ProjectDetail({...@@ -228,6 +260,8 @@ function ProjectDetail({
228 return result;260 return result;
229 }261 }
230 for (const member of group.memberProjects) {262 for (const member of group.memberProjects) {
263 const revoked = checkProjectAccess([member], failureTitle);
264 if (revoked) return revoked;
231 const result = mapAtomCommandResult(265 const result = mapAtomCommandResult(
232 await updateProject({266 await updateProject({
233 environmentId: member.environmentId,267 environmentId: member.environmentId,
...@@ -249,7 +283,7 @@ function ProjectDetail({...@@ -249,7 +283,7 @@ function ProjectDetail({
249 }283 }
250 return AsyncResult.success(undefined);284 return AsyncResult.success(undefined);
251 },285 },
252 [environmentById, group.memberProjects, reportFailure, updateProject],286 [checkProjectAccess, environmentById, group.memberProjects, reportFailure, updateProject],
253 );287 );
254288
255 const renameGroup = useCallback(289 const renameGroup = useCallback(
...@@ -274,8 +308,8 @@ function ProjectDetail({...@@ -274,8 +308,8 @@ function ProjectDetail({
274 );308 );
275309
276 // ----- project icon -----310 // ----- project icon -----
277 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);311 const [faviconPickerOpen, setFaviconPickerOpen] = useComposerMenuState(!canEditGroup);
278 const [iconPickerOpen, setIconPickerOpen] = useState(false);312 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup);
279 const [isSavingFavicon, setIsSavingFavicon] = useState(false);313 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
280 const savingFaviconRef = useRef(false);314 const savingFaviconRef = useRef(false);
281 const setProjectIcon = useCallback(315 const setProjectIcon = useCallback(
...@@ -297,6 +331,7 @@ function ProjectDetail({...@@ -297,6 +331,7 @@ function ProjectDetail({
297331
298 const removeMembers = useCallback(332 const removeMembers = useCallback(
299 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {333 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
334 if (checkProjectAccess(members, "Failed to remove project")) return;
300 const api = readLocalApi();335 const api = readLocalApi();
301 if (!api) return;336 if (!api) return;
302337
...@@ -336,9 +371,11 @@ function ProjectDetail({...@@ -336,9 +371,11 @@ function ProjectDetail({
336 ),371 ),
337 );372 );
338 if (confirmed._tag === "Failure" || !confirmed.value) return;373 if (confirmed._tag === "Failure" || !confirmed.value) return;
374 if (checkProjectAccess(members, "Failed to remove project")) return;
339375
340 const draftStore = useComposerDraftStore.getState();376 const draftStore = useComposerDraftStore.getState();
341 for (const member of members) {377 for (const member of members) {
378 if (checkProjectAccess([member], "Failed to remove project")) return;
342 const memberThreads = projectThreads.filter(379 const memberThreads = projectThreads.filter(
343 (thread) =>380 (thread) =>
344 thread.environmentId === member.environmentId && thread.projectId === member.id,381 thread.environmentId === member.environmentId && thread.projectId === member.id,
...@@ -374,6 +411,7 @@ function ProjectDetail({...@@ -374,6 +411,7 @@ function ProjectDetail({
374 }411 }
375 },412 },
376 [413 [
414 checkProjectAccess,
377 deleteProject,415 deleteProject,
378 group.displayName,416 group.displayName,
379 group.memberProjects.length,417 group.memberProjects.length,
...@@ -395,6 +433,7 @@ function ProjectDetail({...@@ -395,6 +433,7 @@ function ProjectDetail({
395 <Button433 <Button
396 size="sm"434 size="sm"
397 variant="outline"435 variant="outline"
436 disabled={!editableIds.has(member.environmentId)}
398 onClick={() => void removeMembers([member])}437 onClick={() => void removeMembers([member])}
399 aria-label={`Remove checkout ${member.workspaceRoot}`}438 aria-label={`Remove checkout ${member.workspaceRoot}`}
400 >439 >
...@@ -416,6 +455,13 @@ function ProjectDetail({...@@ -416,6 +455,13 @@ function ProjectDetail({
416 </AlertDescription>455 </AlertDescription>
417 </Alert>456 </Alert>
418 <SettingsSection id="project-overview" title="Project" hideTitle>457 <SettingsSection id="project-overview" title="Project" hideTitle>
458 {!canEditGroup ? (
459 <p className="px-3 py-2 text-sm text-muted-foreground sm:px-4">
460 {group.memberProjects.length > 1
461 ? "Shared settings require permission to change every checkout in this group."
462 : "This connection cannot change this project."}
463 </p>
464 ) : null}
419 <SettingsRow465 <SettingsRow
420 title="Name"466 title="Name"
421 description="The shared name for this project group in the sidebar and thread lists."467 description="The shared name for this project group in the sidebar and thread lists."
...@@ -425,6 +471,7 @@ function ProjectDetail({...@@ -425,6 +471,7 @@ function ProjectDetail({
425 size="sm"471 size="sm"
426 className="w-full sm:w-64"472 className="w-full sm:w-64"
427 aria-label="Project name"473 aria-label="Project name"
474 disabled={!canEditGroup}
428 defaultValue={group.displayName}475 defaultValue={group.displayName}
429 onChange={() => {476 onChange={() => {
430 projectNameEditedRef.current = true;477 projectNameEditedRef.current = true;
...@@ -457,7 +504,7 @@ function ProjectDetail({...@@ -457,7 +504,7 @@ function ProjectDetail({
457 ) ? (504 ) ? (
458 <SettingResetButton505 <SettingResetButton
459 label="project icon"506 label="project icon"
460 disabled={isSavingFavicon}507 disabled={isSavingFavicon || !canEditGroup}
461 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}508 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
462 />509 />
463 ) : null510 ) : null
...@@ -470,7 +517,7 @@ function ProjectDetail({...@@ -470,7 +517,7 @@ function ProjectDetail({
470 variant="outline"517 variant="outline"
471 type="button"518 type="button"
472 aria-label="Choose a project icon"519 aria-label="Choose a project icon"
473 disabled={isSavingFavicon}520 disabled={isSavingFavicon || !canEditGroup}
474 onClick={() => setIconPickerOpen(true)}521 onClick={() => setIconPickerOpen(true)}
475 >522 >
476 Choose icon523 Choose icon
...@@ -480,7 +527,7 @@ function ProjectDetail({...@@ -480,7 +527,7 @@ function ProjectDetail({
480 variant="outline"527 variant="outline"
481 type="button"528 type="button"
482 aria-label="Choose a project icon file"529 aria-label="Choose a project icon file"
483 disabled={isSavingFavicon}530 disabled={isSavingFavicon || !canEditGroup}
484 onClick={() => setFaviconPickerOpen(true)}531 onClick={() => setFaviconPickerOpen(true)}
485 >532 >
486 Choose file533 Choose file
...@@ -512,6 +559,7 @@ function ProjectDetail({...@@ -512,6 +559,7 @@ function ProjectDetail({
512 <Button559 <Button
513 size="sm"560 size="sm"
514 variant="destructive-outline"561 variant="destructive-outline"
562 disabled={!canEditGroup}
515 onClick={() => void removeMembers(group.memberProjects)}563 onClick={() => void removeMembers(group.memberProjects)}
516 >564 >
517 <Trash2Icon />565 <Trash2Icon />
...@@ -535,10 +583,10 @@ function ProjectDetail({...@@ -535,10 +583,10 @@ function ProjectDetail({
535 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }583 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
536 : {})}584 : {})}
537 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}585 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
538 open={faviconPickerOpen}586 open={faviconPickerOpen && canEditGroup}
539 projectName={group.displayName}587 projectName={group.displayName}
540 />588 />
541 {iconPickerOpen ? (589 {iconPickerOpen && canEditGroup ? (
542 <Suspense fallback={null}>590 <Suspense fallback={null}>
543 <ProjectIconPickerDialog591 <ProjectIconPickerDialog
544 current={projectIcon}592 current={projectIcon}
apps/web/src/components/settings/ProviderInstanceCard.tsx+14-15
...@@ -478,6 +478,7 @@ interface ProviderInstanceCardProps {...@@ -478,6 +478,7 @@ interface ProviderInstanceCardProps {
478 readonly selected?: boolean | undefined;478 readonly selected?: boolean | undefined;
479 readonly onSelect?: (() => void) | undefined;479 readonly onSelect?: (() => void) | undefined;
480 readonly readOnly?: boolean | undefined;480 readonly readOnly?: boolean | undefined;
481 readonly canWriteSettings?: boolean;
481 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;482 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
482 /**483 /**
483 * Pass `undefined` to hide the delete footer entirely. Built-in default484 * Pass `undefined` to hide the delete footer entirely. Built-in default
...@@ -546,6 +547,7 @@ export function ProviderInstanceCard({...@@ -546,6 +547,7 @@ export function ProviderInstanceCard({
546 selected = false,547 selected = false,
547 onSelect,548 onSelect,
548 readOnly = false,549 readOnly = false,
550 canWriteSettings = true,
549 onUpdate,551 onUpdate,
550 onDelete,552 onDelete,
551 headerAction,553 headerAction,
...@@ -1116,30 +1118,27 @@ export function ProviderInstanceCard({...@@ -1116,30 +1118,27 @@ export function ProviderInstanceCard({
1116 environment={genericEnvironment}1118 environment={genericEnvironment}
1117 onChange={updateGenericEnvironment}1119 onChange={updateGenericEnvironment}
1118 />1120 />
1119 {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? (
1120 <AcpSessionManagementSection
1121 environmentId={environmentId}
1122 instanceId={instanceId}
1123 provider={liveProvider}
1124 projects={acpProjects}
1125 readOnly={readOnly}
1126 />
1127 ) : null}
1128 </SettingsSection>1121 </SettingsSection>
1122 {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? (
1123 <AcpSessionManagementSection
1124 environmentId={environmentId}
1125 instanceId={instanceId}
1126 provider={liveProvider}
1127 projects={acpProjects}
1128 readOnly={readOnly}
1129 />
1130 ) : null}
11291131
1130 {driverOption !== undefined ? (1132 {driverOption !== undefined ? (
1131 <SettingsSection1133 <SettingsSection title="Models">
1132 title="Models"
1133 inert={readOnly}
1134 aria-disabled={readOnly || undefined}
1135 className={readOnly ? "opacity-50 select-none" : undefined}
1136 >
1137 <div className="px-3 py-3 sm:px-4">1134 <div className="px-3 py-3 sm:px-4">
1138 <p className="mb-3 text-xs text-muted-foreground">1135 <p className="mb-3 text-xs text-muted-foreground">
1139 Favorites, visibility, and ordering are saved on this device. Custom models are saved1136 Favorites, visibility, and ordering are saved on this device. Custom models are saved
1140 on the selected environment.1137 on the selected environment.
1141 </p>1138 </p>
1142 <ProviderModelsSection1139 <ProviderModelsSection
1140 canManageCustomModels={!readOnly}
1141 canWritePreferences={canWriteSettings}
1143 instanceId={instanceId}1142 instanceId={instanceId}
1144 driverKind={driverKind}1143 driverKind={driverKind}
1145 models={modelsForDisplay}1144 models={modelsForDisplay}
apps/web/src/components/settings/ProviderModelsSection.tsx+22-10
...@@ -131,6 +131,8 @@ interface ProviderModelsSectionProps {...@@ -131,6 +131,8 @@ interface ProviderModelsSectionProps {
131 * removed) via `onChange`.131 * removed) via `onChange`.
132 */132 */
133 readonly customModels: ReadonlyArray<CustomModelDefinition>;133 readonly customModels: ReadonlyArray<CustomModelDefinition>;
134 readonly canManageCustomModels: boolean;
135 readonly canWritePreferences?: boolean;
134 /** Server-returned model slugs hidden from the model picker. */136 /** Server-returned model slugs hidden from the model picker. */
135 readonly hiddenModels: ReadonlyArray<string>;137 readonly hiddenModels: ReadonlyArray<string>;
136 /** Model slugs favorited for this provider instance. */138 /** Model slugs favorited for this provider instance. */
...@@ -164,6 +166,8 @@ export function ProviderModelsSection({...@@ -164,6 +166,8 @@ export function ProviderModelsSection({
164 driverKind,166 driverKind,
165 models,167 models,
166 customModels,168 customModels,
169 canManageCustomModels,
170 canWritePreferences = true,
167 hiddenModels,171 hiddenModels,
168 favoriteModels,172 favoriteModels,
169 modelOrder,173 modelOrder,
...@@ -224,7 +228,7 @@ export function ProviderModelsSection({...@@ -224,7 +228,7 @@ export function ProviderModelsSection({
224 }, [displayModels]);228 }, [displayModels]);
225229
226 const handleAdd = () => {230 const handleAdd = () => {
227 if (driverKind === "antigravity") return;231 if (!canManageCustomModels || driverKind === "antigravity") return;
228 const normalized = normalizeCustomModelSlug(input);232 const normalized = normalizeCustomModelSlug(input);
229 if (!normalized) {233 if (!normalized) {
230 setError("Enter a model slug.");234 setError("Enter a model slug.");
...@@ -260,6 +264,7 @@ export function ProviderModelsSection({...@@ -260,6 +264,7 @@ export function ProviderModelsSection({
260 };264 };
261265
262 const handleRemove = (slug: string) => {266 const handleRemove = (slug: string) => {
267 if (!canManageCustomModels) return;
263 if (editingSlug === slug) setEditingSlug(null);268 if (editingSlug === slug) setEditingSlug(null);
264 onChange(customModels.filter((entry) => entry.slug !== slug));269 onChange(customModels.filter((entry) => entry.slug !== slug));
265 onModelOrderChange(modelOrder.filter((model) => model !== slug));270 onModelOrderChange(modelOrder.filter((model) => model !== slug));
...@@ -268,18 +273,20 @@ export function ProviderModelsSection({...@@ -268,18 +273,20 @@ export function ProviderModelsSection({
268 };273 };
269274
270 const handleSaveEdit = (next: CustomModelDefinition) => {275 const handleSaveEdit = (next: CustomModelDefinition) => {
276 if (!canManageCustomModels) return;
271 onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));277 onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));
272 setEditingSlug(null);278 setEditingSlug(null);
273 };279 };
274280
275 const setHidden = (slug: string, hidden: boolean) => {281 const setHidden = (slug: string, hidden: boolean) => {
276 if (hidden === hiddenModelSet.has(slug)) return;282 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;
277 onHiddenModelsChange(283 onHiddenModelsChange(
278 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),284 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
279 );285 );
280 };286 };
281287
282 const handleToggleFavorite = (slug: string) => {288 const handleToggleFavorite = (slug: string) => {
289 if (!canWritePreferences) return;
283 if (favoriteModelSet.has(slug)) {290 if (favoriteModelSet.has(slug)) {
284 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));291 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
285 return;292 return;
...@@ -296,6 +303,7 @@ export function ProviderModelsSection({...@@ -296,6 +303,7 @@ export function ProviderModelsSection({
296 ? "hidden"303 ? "hidden"
297 : "visible";304 : "visible";
298 const handleMove = (slug: string, direction: -1 | 1) => {305 const handleMove = (slug: string, direction: -1 | 1) => {
306 if (!canWritePreferences) return;
299 const index = displayModels.findIndex((model) => model.slug === slug);307 const index = displayModels.findIndex((model) => model.slug === slug);
300 const nextIndex = index + direction;308 const nextIndex = index + direction;
301 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;309 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;
...@@ -314,6 +322,7 @@ export function ProviderModelsSection({...@@ -314,6 +322,7 @@ export function ProviderModelsSection({
314 <Button322 <Button
315 size="icon-micro"323 size="icon-micro"
316 variant="ghost-muted"324 variant="ghost-muted"
325 disabled={!canWritePreferences}
317 onClick={() => handleToggleFavorite(model.slug)}326 onClick={() => handleToggleFavorite(model.slug)}
318 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${327 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${
319 isFavorite ? "from" : "to"328 isFavorite ? "from" : "to"
...@@ -348,7 +357,7 @@ export function ProviderModelsSection({...@@ -348,7 +357,7 @@ export function ProviderModelsSection({
348 <Button357 <Button
349 size="icon-micro"358 size="icon-micro"
350 variant="ghost-muted"359 variant="ghost-muted"
351 disabled={!options.canMoveUp}360 disabled={!canWritePreferences || !options.canMoveUp}
352 onClick={() => handleMove(model.slug, -1)}361 onClick={() => handleMove(model.slug, -1)}
353 aria-label={`Move ${model.name} up`}362 aria-label={`Move ${model.name} up`}
354 />363 />
...@@ -364,7 +373,7 @@ export function ProviderModelsSection({...@@ -364,7 +373,7 @@ export function ProviderModelsSection({
364 <Button373 <Button
365 size="icon-micro"374 size="icon-micro"
366 variant="ghost-muted"375 variant="ghost-muted"
367 disabled={!options.canMoveDown}376 disabled={!canWritePreferences || !options.canMoveDown}
368 onClick={() => handleMove(model.slug, 1)}377 onClick={() => handleMove(model.slug, 1)}
369 aria-label={`Move ${model.name} down`}378 aria-label={`Move ${model.name} down`}
370 />379 />
...@@ -384,10 +393,12 @@ export function ProviderModelsSection({...@@ -384,10 +393,12 @@ export function ProviderModelsSection({
384 <Button393 <Button
385 size="icon-micro"394 size="icon-micro"
386 variant="ghost-muted"395 variant="ghost-muted"
396 disabled={!canManageCustomModels}
387 aria-label={`Edit ${model.slug}`}397 aria-label={`Edit ${model.slug}`}
388 onClick={() =>398 onClick={() => {
389 setEditingSlug((current) => (current === model.slug ? null : model.slug))399 if (!canManageCustomModels) return;
390 }400 setEditingSlug((current) => (current === model.slug ? null : model.slug));
401 }}
391 />402 />
392 }403 }
393 >404 >
...@@ -401,6 +412,7 @@ export function ProviderModelsSection({...@@ -401,6 +412,7 @@ export function ProviderModelsSection({
401 <Button412 <Button
402 size="icon-micro"413 size="icon-micro"
403 variant="ghost-muted"414 variant="ghost-muted"
415 disabled={!canManageCustomModels}
404 aria-label={`Remove ${model.slug}`}416 aria-label={`Remove ${model.slug}`}
405 onClick={() => handleRemove(model.slug)}417 onClick={() => handleRemove(model.slug)}
406 />418 />
...@@ -430,7 +442,7 @@ export function ProviderModelsSection({...@@ -430,7 +442,7 @@ export function ProviderModelsSection({
430 <Switch442 <Switch
431 size="sm"443 size="sm"
432 checked={!isHidden}444 checked={!isHidden}
433 disabled={model.isCustom}445 disabled={!canWritePreferences || model.isCustom}
434 onCheckedChange={(checked) => setHidden(model.slug, !checked)}446 onCheckedChange={(checked) => setHidden(model.slug, !checked)}
435 aria-label={`Show ${model.name} in the model picker`}447 aria-label={`Show ${model.name} in the model picker`}
436 />448 />
...@@ -562,7 +574,7 @@ export function ProviderModelsSection({...@@ -562,7 +574,7 @@ export function ProviderModelsSection({
562 const previous = visibleModels[index - 1];574 const previous = visibleModels[index - 1];
563 const startsGroup = previous === undefined || groupOf(previous) !== group;575 const startsGroup = previous === undefined || groupOf(previous) !== group;
564 const editingEntry =576 const editingEntry =
565 model.isCustom && editingSlug === model.slug577 canManageCustomModels && model.isCustom && editingSlug === model.slug
566 ? customModels.find((entry) => entry.slug === model.slug)578 ? customModels.find((entry) => entry.slug === model.slug)
567 : undefined;579 : undefined;
568 return (580 return (
...@@ -593,7 +605,7 @@ export function ProviderModelsSection({...@@ -593,7 +605,7 @@ export function ProviderModelsSection({
593 })}605 })}
594 </div>606 </div>
595607
596 {driverKind === "antigravity" ? null : isAdding ? (608 {driverKind === "antigravity" || !canManageCustomModels ? null : isAdding ? (
597 <div className="mt-3 flex flex-col gap-2 sm:flex-row">609 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
598 <Input610 <Input
599 id={`provider-instance-${instanceId}-custom-model`}611 id={`provider-instance-${instanceId}-custom-model`}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+39-1
...@@ -26,6 +26,8 @@ const commands = vi.hoisted(() => ({...@@ -26,6 +26,8 @@ const commands = vi.hoisted(() => ({
26 updateProvider: vi.fn(),26 updateProvider: vi.fn(),
27 uninstall: vi.fn(),27 uninstall: vi.fn(),
28 acceptUrlAuth: vi.fn(),28 acceptUrlAuth: vi.fn(),
29 canManageProviders: true,
30 canWriteSettings: true,
29}));31}));
3032
31const settingsState = vi.hoisted(() => ({33const settingsState = vi.hoisted(() => ({
...@@ -118,6 +120,20 @@ vi.mock("../../environments/primary", () => ({...@@ -118,6 +120,20 @@ vi.mock("../../environments/primary", () => ({
118120
119vi.mock("../../state/session", () => ({121vi.mock("../../state/session", () => ({
120 useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }),122 useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }),
123 useEnvironmentScope: (environmentId: EnvironmentId, scope: string) =>
124 environmentId === "remote-device" &&
125 (scope === "providers:manage"
126 ? commands.canManageProviders
127 : scope === "settings:write"
128 ? commands.canWriteSettings
129 : scope === "orchestration:read"),
130 readEnvironmentScope: (environmentId: EnvironmentId, scope: string) =>
131 environmentId === "remote-device" &&
132 (scope === "providers:manage"
133 ? commands.canManageProviders
134 : scope === "settings:write"
135 ? commands.canWriteSettings
136 : scope === "orchestration:read"),
121}));137}));
122138
123vi.mock("../../state/entities", () => ({139vi.mock("../../state/entities", () => ({
...@@ -125,6 +141,7 @@ vi.mock("../../state/entities", () => ({...@@ -125,6 +141,7 @@ vi.mock("../../state/entities", () => ({
125}));141}));
126142
127import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";143import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
144import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
128145
129const environmentId = EnvironmentId.make("remote-device");146const environmentId = EnvironmentId.make("remote-device");
130const codexId = ProviderInstanceId.make("codex");147const codexId = ProviderInstanceId.make("codex");
...@@ -209,6 +226,8 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -209,6 +226,8 @@ describe("EnvironmentProviderSettings routing", () => {
209 settingsState.mutateProviderInstance226 settingsState.mutateProviderInstance
210 .mockReset()227 .mockReset()
211 .mockResolvedValue({ _tag: "Success", value: {} });228 .mockResolvedValue({ _tag: "Success", value: {} });
229 commands.canManageProviders = true;
230 commands.canWriteSettings = true;
212 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });231 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
213 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });232 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });
214 commands.uninstall.mockReset().mockResolvedValue({ _tag: "Success", value: {} });233 commands.uninstall.mockReset().mockResolvedValue({ _tag: "Success", value: {} });
...@@ -355,6 +374,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -355,6 +374,7 @@ describe("EnvironmentProviderSettings routing", () => {
355 });374 });
356375
357 it("keeps provider selection available while write controls are read only", () => {376 it("keeps provider selection available while write controls are read only", () => {
377 commands.canWriteSettings = false;
358 settingsState.value = {378 settingsState.value = {
359 ...DEFAULT_UNIFIED_SETTINGS,379 ...DEFAULT_UNIFIED_SETTINGS,
360 providerInstances: {380 providerInstances: {
...@@ -388,7 +408,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -388,7 +408,7 @@ describe("EnvironmentProviderSettings routing", () => {
388 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");408 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
389 expect(notice).not.toBeNull();409 expect(notice).not.toBeNull();
390410
391 expect(visitElements(panel, isRefreshButton)).toBeNull();411 expect(visitElements(panel, isRefreshButton)).not.toBeNull();
392 expect(visitElements(panel, isAddProviderButton)).toBeNull();412 expect(visitElements(panel, isAddProviderButton)).toBeNull();
393 });413 });
394414
...@@ -403,6 +423,24 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -403,6 +423,24 @@ describe("EnvironmentProviderSettings routing", () => {
403 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();423 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
404 });424 });
405425
426 it("removes an open add-instance dialog when the provider grant is revoked", () => {
427 let panel = renderPanel();
428 const add = visitElements(panel, isAddProviderButton);
429 if (!add) throw new Error("Missing Add provider action.");
430 (add.props.onClick as () => void)();
431 panel = renderPanel();
432 expect(
433 visitElements(panel, (element) => element.type === AddProviderInstanceDialog),
434 ).not.toBeNull();
435
436 commands.canManageProviders = false;
437 panel = renderPanel({ readOnly: true });
438 expect(
439 visitElements(panel, (element) => element.type === AddProviderInstanceDialog),
440 ).toBeNull();
441 expect(settingsState.updateSettings).not.toHaveBeenCalled();
442 });
443
406 it("keeps Advanced visible when search targets the provider health interval", () => {444 it("keeps Advanced visible when search targets the provider health interval", () => {
407 let panel = renderPanel();445 let panel = renderPanel();
408 expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull();446 expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull();
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+21-14
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts";1import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
...@@ -136,7 +136,7 @@ describe("provider environment access", () => {...@@ -136,7 +136,7 @@ describe("provider environment access", () => {
136describe("primary operate access", () => {136describe("primary operate access", () => {
137 const authenticated = {137 const authenticated = {
138 authenticated: true as const,138 authenticated: true as const,
139 scopes: [AuthOrchestrationOperateScope],139 scopes: [AuthProvidersManageScope],
140 };140 };
141141
142 it("keeps cached session data authoritative while SWR revalidates", () => {142 it("keeps cached session data authoritative while SWR revalidates", () => {
...@@ -163,7 +163,7 @@ describe("primary operate access", () => {...@@ -163,7 +163,7 @@ describe("primary operate access", () => {
163 ).toBe("pending");163 ).toBe("pending");
164 });164 });
165165
166 it("treats a failed session fetch as a transport problem, not a denial", () => {166 it("denies writes when the session fetch fails", () => {
167 expect(167 expect(
168 resolvePrimaryOperateAccess({168 resolvePrimaryOperateAccess({
169 isPrimary: true,169 isPrimary: true,
...@@ -172,7 +172,7 @@ describe("primary operate access", () => {...@@ -172,7 +172,7 @@ describe("primary operate access", () => {
172 isPending: false,172 isPending: false,
173 hasError: true,173 hasError: true,
174 }),174 }),
175 ).toBe("granted");175 ).toBe("denied");
176 });176 });
177177
178 it("denies unauthenticated sessions and sessions without the operate scope", () => {178 it("denies unauthenticated sessions and sessions without the operate scope", () => {
...@@ -205,7 +205,7 @@ describe("primary operate access", () => {...@@ -205,7 +205,7 @@ describe("primary operate access", () => {
205 ).toBe("denied");205 ).toBe("denied");
206 });206 });
207207
208 it("grants desktop bridge and remote environments without blocking on the primary session", () => {208 it("waits for explicit grants on desktop and remote environments", () => {
209 expect(209 expect(
210 resolvePrimaryOperateAccess({210 resolvePrimaryOperateAccess({
211 isPrimary: true,211 isPrimary: true,
...@@ -214,7 +214,7 @@ describe("primary operate access", () => {...@@ -214,7 +214,7 @@ describe("primary operate access", () => {
214 isPending: true,214 isPending: true,
215 hasError: false,215 hasError: false,
216 }),216 }),
217 ).toBe("granted");217 ).toBe("pending");
218 expect(218 expect(
219 resolvePrimaryOperateAccess({219 resolvePrimaryOperateAccess({
220 isPrimary: false,220 isPrimary: false,
...@@ -223,15 +223,24 @@ describe("primary operate access", () => {...@@ -223,15 +223,24 @@ describe("primary operate access", () => {
223 isPending: true,223 isPending: true,
224 hasError: false,224 hasError: false,
225 }),225 }),
226 ).toBe("granted");226 ).toBe("pending");
227 });227 });
228});228});
229229
230describe("remote operate access", () => {230describe("remote operate access", () => {
231 it("does not treat the old orchestration grant as provider management", () => {
232 expect(
233 resolveRemoteOperateAccess({
234 session: { authenticated: true, scopes: ["orchestration:operate"] },
235 isPending: false,
236 hasError: false,
237 }),
238 ).toBe("denied");
239 });
231 it("derives access from the environment session's granted scopes", () => {240 it("derives access from the environment session's granted scopes", () => {
232 expect(241 expect(
233 resolveRemoteOperateAccess({242 resolveRemoteOperateAccess({
234 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },243 session: { authenticated: true, scopes: [AuthProvidersManageScope] },
235 isPending: false,244 isPending: false,
236 hasError: false,245 hasError: false,
237 }),246 }),
...@@ -258,18 +267,16 @@ describe("remote operate access", () => {...@@ -258,18 +267,16 @@ describe("remote operate access", () => {
258 );267 );
259 expect(268 expect(
260 resolveRemoteOperateAccess({269 resolveRemoteOperateAccess({
261 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },270 session: { authenticated: true, scopes: [AuthProvidersManageScope] },
262 isPending: true,271 isPending: true,
263 hasError: false,272 hasError: false,
264 }),273 }),
265 ).toBe("granted");274 ).toBe("granted");
266 });275 });
267276
268 it("stays optimistic when the session fetch fails or an older server omits scopes", () => {277 it("denies writes when the session fetch fails or scopes are missing", () => {
269 // Transport failures and pre-scope-reporting servers are not permission
270 // decisions; the environment RPC layer still rejects unauthorized writes.
271 expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe(278 expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe(
272 "granted",279 "denied",
273 );280 );
274 expect(281 expect(
275 resolveRemoteOperateAccess({282 resolveRemoteOperateAccess({
...@@ -277,6 +284,6 @@ describe("remote operate access", () => {...@@ -277,6 +284,6 @@ describe("remote operate access", () => {
277 isPending: false,284 isPending: false,
278 hasError: false,285 hasError: false,
279 }),286 }),
280 ).toBe("granted");287 ).toBe("denied");
281 });288 });
282});289});
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+9-51
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import {2import {
3 AuthOrchestrationOperateScope,3 AuthProvidersManageScope,
4 type AuthSessionState,4 type AuthSessionState,
5 type EnvironmentId,5 type EnvironmentId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -74,46 +74,19 @@ export type ProviderEnvironmentAccess =...@@ -74,46 +74,19 @@ export type ProviderEnvironmentAccess =
74 */74 */
75export type ProviderOperateAccess = "granted" | "denied" | "pending";75export type ProviderOperateAccess = "granted" | "denied" | "pending";
7676
77/**77/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */
78 * Resolve operate access from an environment's `/api/auth/session` answer.
79 *
80 * Cached session data wins over an in-flight revalidation. The session atoms
81 * are SWR-backed, so they report `isPending` on every background refresh;
82 * treating that as unknown would flip a working panel back to loading and
83 * discard in-progress edits.
84 *
85 * `missingScopesAccess` decides the case where the session resolved but did
86 * not report scopes: the primary serves the web app itself so its server
87 * always reports them (absence means denial), while a remote device may run an
88 * older server version that predates scope reporting, where denial would lock
89 * out a legitimate session. The environment RPC layer stays authoritative
90 * either way.
91 */
92function resolveSessionOperateAccess(input: {78function resolveSessionOperateAccess(input: {
93 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;79 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
94 readonly isPending: boolean;80 readonly isPending: boolean;
95 readonly hasError: boolean;81 readonly hasError: boolean;
96 readonly missingScopesAccess: "granted" | "denied";
97}): ProviderOperateAccess {82}): ProviderOperateAccess {
98 if (input.session === null) {83 if (input.hasError) return "denied";
99 if (input.isPending) {84 if (input.session === null) return input.isPending ? "pending" : "denied";
100 return "pending";85 return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope)
101 }86 ? "granted"
102 // A failed session fetch is a transport problem, not a permission87 : "denied";
103 // decision — locking the panel read-only would misreport it. Stay
104 // optimistic; the environment RPC layer still rejects unauthorized writes.
105 return input.hasError ? "granted" : "denied";
106 }
107 if (!input.session.authenticated) {
108 return "denied";
109 }
110 if (input.session.scopes === undefined) {
111 return input.missingScopesAccess;
112 }
113 return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied";
114}88}
11589
116/** Operate access for the primary environment's own browser session. */
117export function resolvePrimaryOperateAccess(input: {90export function resolvePrimaryOperateAccess(input: {
118 readonly isPrimary: boolean;91 readonly isPrimary: boolean;
119 readonly hasDesktopBridge: boolean;92 readonly hasDesktopBridge: boolean;
...@@ -121,30 +94,15 @@ export function resolvePrimaryOperateAccess(input: {...@@ -121,30 +94,15 @@ export function resolvePrimaryOperateAccess(input: {
121 readonly isPending: boolean;94 readonly isPending: boolean;
122 readonly hasError: boolean;95 readonly hasError: boolean;
123}): ProviderOperateAccess {96}): ProviderOperateAccess {
124 if (!input.isPrimary || input.hasDesktopBridge) {97 return resolveSessionOperateAccess(input);
125 return "granted";
126 }
127 return resolveSessionOperateAccess({
128 session: input.session,
129 isPending: input.isPending,
130 hasError: input.hasError,
131 missingScopesAccess: "denied",
132 });
133}98}
13499
135/**
136 * Operate access for a non-primary environment, derived from the scopes its
137 * `/api/auth/session` endpoint reports for this client's credential.
138 */
139export function resolveRemoteOperateAccess(input: {100export function resolveRemoteOperateAccess(input: {
140 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;101 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
141 readonly isPending: boolean;102 readonly isPending: boolean;
142 readonly hasError: boolean;103 readonly hasError: boolean;
143}): ProviderOperateAccess {104}): ProviderOperateAccess {
144 return resolveSessionOperateAccess({105 return resolveSessionOperateAccess(input);
145 ...input,
146 missingScopesAccess: "granted",
147 });
148}106}
149107
150export function classifyProviderEnvironmentAccess(input: {108export function classifyProviderEnvironmentAccess(input: {
apps/web/src/components/settings/ProviderSettingsPanel.tsx+67-110
...@@ -1,5 +1,11 @@...@@ -1,5 +1,11 @@
1import { SettingsGroup } from "./SettingsGroup";1import { SettingsGroup } from "./SettingsGroup";
2import { RefreshIcon } from "~/components/ui/refresh-icon";2import { RefreshIcon } from "~/components/ui/refresh-icon";
3import {
4 AuthSettingsWriteScope,
5 AuthProvidersManageScope,
6 AuthOrchestrationReadScope,
7} from "@t3tools/contracts";
8import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
3import { useAtomValue } from "@effect/atom-react";9import { useAtomValue } from "@effect/atom-react";
4import { connectionStatusTitle } from "@t3tools/client-runtime/connection";10import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
5import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";11import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
...@@ -31,8 +37,6 @@ import { PlusIcon } from "lucide-react";...@@ -31,8 +37,6 @@ import { PlusIcon } from "lucide-react";
31import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";37import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3238
33import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";39import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
34import { isElectron } from "../../env";
35import { usePrimarySessionState } from "../../environments/primary";
36import {40import {
37 useEnvironmentSettings,41 useEnvironmentSettings,
38 usePersistEnvironmentProviderInstanceMutation,42 usePersistEnvironmentProviderInstanceMutation,
...@@ -115,7 +119,6 @@ import {...@@ -115,7 +119,6 @@ import {
115 isProviderSettingsEnvironmentAvailable,119 isProviderSettingsEnvironmentAvailable,
116 type ProviderEnvironmentAccess,120 type ProviderEnvironmentAccess,
117 type ProviderOperateAccess,121 type ProviderOperateAccess,
118 resolvePrimaryOperateAccess,
119 resolveRemoteOperateAccess,122 resolveRemoteOperateAccess,
120 resolveSelectedProviderEnvironmentId,123 resolveSelectedProviderEnvironmentId,
121} from "./ProviderSettingsPanel.logic";124} from "./ProviderSettingsPanel.logic";
...@@ -465,28 +468,6 @@ function SelectedEnvironmentProviderSettings({...@@ -465,28 +468,6 @@ function SelectedEnvironmentProviderSettings({
465 readonly deviceTabs?: ReactNode;468 readonly deviceTabs?: ReactNode;
466 readonly targetInstanceId?: ProviderInstanceId | undefined;469 readonly targetInstanceId?: ProviderInstanceId | undefined;
467}) {470}) {
468 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
469 if (isPrimary) {
470 // The desktop app owns its primary server outright; a browser session
471 // checks the scopes its cookie session was granted.
472 if (isElectron) {
473 return (
474 <AccessGatedProviderSettings
475 environment={environment}
476 operateAccess="granted"
477 deviceTabs={deviceTabs}
478 targetInstanceId={targetInstanceId}
479 />
480 );
481 }
482 return (
483 <PrimarySessionGatedProviderSettings
484 environment={environment}
485 deviceTabs={deviceTabs}
486 targetInstanceId={targetInstanceId}
487 />
488 );
489 }
490 return (471 return (
491 <RemoteSessionGatedProviderSettings472 <RemoteSessionGatedProviderSettings
492 environment={environment}473 environment={environment}
...@@ -496,33 +477,6 @@ function SelectedEnvironmentProviderSettings({...@@ -496,33 +477,6 @@ function SelectedEnvironmentProviderSettings({
496 );477 );
497}478}
498479
499function PrimarySessionGatedProviderSettings({
500 environment,
501 deviceTabs,
502 targetInstanceId,
503}: {
504 readonly environment: EnvironmentPresentation;
505 readonly deviceTabs?: ReactNode;
506 readonly targetInstanceId?: ProviderInstanceId | undefined;
507}) {
508 const primarySessionState = usePrimarySessionState();
509 const operateAccess = resolvePrimaryOperateAccess({
510 isPrimary: true,
511 hasDesktopBridge: false,
512 session: primarySessionState.data,
513 isPending: primarySessionState.isPending,
514 hasError: primarySessionState.error !== null,
515 });
516 return (
517 <AccessGatedProviderSettings
518 environment={environment}
519 operateAccess={operateAccess}
520 deviceTabs={deviceTabs}
521 targetInstanceId={targetInstanceId}
522 />
523 );
524}
525
526function RemoteSessionGatedProviderSettings({480function RemoteSessionGatedProviderSettings({
527 environment,481 environment,
528 deviceTabs,482 deviceTabs,
...@@ -597,15 +551,15 @@ export function EnvironmentProviderSettings({...@@ -597,15 +551,15 @@ export function EnvironmentProviderSettings({
597 readonly targetInstanceId?: ProviderInstanceId | undefined;551 readonly targetInstanceId?: ProviderInstanceId | undefined;
598 /**552 /**
599 * Grey out and freeze every write control when this session's credential553 * Grey out and freeze every write control when this session's credential
600 * lacks `orchestration:operate` on the environment. Selecting providers554 * lacks `providers:manage` on the environment. Selecting providers
601 * still works so the real configuration stays readable; switches, forms,555 * still works so the real configuration stays readable; switches, forms,
602 * and the health interval are inert so no write is offered and then rejected.556 * are inert so no write is offered and then rejected.
603 */557 */
604 readonly readOnly?: boolean;558 readonly readOnly?: boolean;
605}) {559}) {
606 const settings = useEnvironmentSettings(environmentId);560 const settings = useEnvironmentSettings(environmentId);
607 // Provider instances hold per-machine credentials and binaries, so this561 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
608 // page always edits exactly the environment it displays.562 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
609 const updateSettings = useUpdateEnvironmentSettings(environmentId);563 const updateSettings = useUpdateEnvironmentSettings(environmentId);
610 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);564 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
611 const updateClientSettings = useUpdateClientSettings();565 const updateClientSettings = useUpdateClientSettings();
...@@ -700,7 +654,8 @@ export function EnvironmentProviderSettings({...@@ -700,7 +654,8 @@ export function EnvironmentProviderSettings({
700 : null;654 : null;
701655
702 const refreshProviders = useCallback(() => {656 const refreshProviders = useCallback(() => {
703 if (refreshingRef.current) return;657 if (refreshingRef.current || !readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
658 return;
704 refreshingRef.current = true;659 refreshingRef.current = true;
705 setIsRefreshingProviders(true);660 setIsRefreshingProviders(true);
706 void (async () => {661 void (async () => {
...@@ -725,6 +680,7 @@ export function EnvironmentProviderSettings({...@@ -725,6 +680,7 @@ export function EnvironmentProviderSettings({
725 candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">,680 candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">,
726 targetVersion?: string,681 targetVersion?: string,
727 ) => {682 ) => {
683 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
728 // Ref-based re-entry guard, mirroring refreshProviders: a state updater684 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
729 // may run after this function returns, so it cannot gate the dispatch.685 // may run after this function returns, so it cannot gate the dispatch.
730 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {686 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {
...@@ -1062,6 +1018,7 @@ export function EnvironmentProviderSettings({...@@ -1062,6 +1018,7 @@ export function EnvironmentProviderSettings({
1062 />1018 />
1063 ) : undefined1019 ) : undefined
1064 }1020 }
1021 canWriteSettings={canWriteSettings}
1065 setup={1022 setup={
1066 mode === "editor" && row.driver === "antigravity" ? (1023 mode === "editor" && row.driver === "antigravity" ? (
1067 <ProviderSetupSection1024 <ProviderSetupSection
...@@ -1125,7 +1082,10 @@ export function EnvironmentProviderSettings({...@@ -1125,7 +1082,10 @@ export function EnvironmentProviderSettings({
1125 onUpdate={(next) => {1082 onUpdate={(next) => {
1126 const wasEnabled = resolveProviderInstanceEnabled(row.instance);1083 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
1127 const isDisabling = next.enabled === false && wasEnabled;1084 const isDisabling = next.enabled === false && wasEnabled;
1128 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;1085 const shouldClearTextGen =
1086 isDisabling &&
1087 textGenInstanceId === row.instanceId &&
1088 readEnvironmentScope(environmentId, AuthSettingsWriteScope);
1129 updateProviderInstance(1089 updateProviderInstance(
1130 row,1090 row,
1131 next,1091 next,
...@@ -1197,53 +1157,47 @@ export function EnvironmentProviderSettings({...@@ -1197,53 +1157,47 @@ export function EnvironmentProviderSettings({
1197 headerAction={1157 headerAction={
1198 <div className="flex min-w-0 items-center gap-2">1158 <div className="flex min-w-0 items-center gap-2">
1199 <ProviderUpdatesAction />1159 <ProviderUpdatesAction />
1200 {readOnly ? (1160 <Tooltip>
1201 <span className="min-w-0 truncate text-xs text-muted-foreground">1161 <TooltipTrigger
1202 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />1162 render={
1203 </span>1163 <Button
1204 ) : (1164 size="xs"
1205 <>1165 variant="ghost-muted"
1206 <Tooltip>1166 disabled={isRefreshingProviders || !canRefreshProviders}
1207 <TooltipTrigger1167 aria-busy={isRefreshingProviders}
1208 render={1168 onClick={() => void refreshProviders()}
1209 <Button1169 >
1210 size="xs"1170 <RefreshIcon refreshing={isRefreshingProviders} />
1211 variant="ghost-muted"1171 <span className="sr-only">Refresh provider status</span>
1212 disabled={isRefreshingProviders}1172 <span className="hidden min-w-0 truncate sm:inline">
1213 aria-busy={isRefreshingProviders}1173 {isRefreshingProviders ? (
1214 onClick={() => void refreshProviders()}1174 "Refreshing providers"
1215 >1175 ) : (
1216 <RefreshIcon refreshing={isRefreshingProviders} />1176 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1217 <span className="sr-only">Refresh provider status</span>1177 )}
1218 <span className="hidden min-w-0 truncate sm:inline">1178 </span>
1219 {isRefreshingProviders ? (1179 </Button>
1220 "Refreshing providers"1180 }
1221 ) : (1181 />
1222 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />1182 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1223 )}1183 </Tooltip>
1224 </span>1184 {!readOnly ? (
1225 </Button>1185 <Tooltip>
1226 }1186 <TooltipTrigger
1227 />1187 render={
1228 <TooltipPopup side="top">Refresh provider status</TooltipPopup>1188 <Button
1229 </Tooltip>1189 size="icon-xs"
1230 <Tooltip>1190 variant="ghost-muted"
1231 <TooltipTrigger1191 onClick={() => setIsAddInstanceDialogOpen(true)}
1232 render={1192 aria-label="Add provider"
1233 <Button1193 >
1234 size="icon-xs"1194 <PlusIcon />
1235 variant="ghost-muted"1195 </Button>
1236 onClick={() => setIsAddInstanceDialogOpen(true)}1196 }
1237 aria-label="Add provider"1197 />
1238 >1198 <TooltipPopup side="top">Add provider</TooltipPopup>
1239 <PlusIcon />1199 </Tooltip>
1240 </Button>1200 ) : null}
1241 }
1242 />
1243 <TooltipPopup side="top">Add provider</TooltipPopup>
1244 </Tooltip>
1245 </>
1246 )}
1247 </div>1201 </div>
1248 }1202 }
1249 >1203 >
...@@ -1318,7 +1272,10 @@ export function EnvironmentProviderSettings({...@@ -1318,7 +1272,10 @@ export function EnvironmentProviderSettings({
1318 description="Refresh provider status, versions, and models in the background. Set to 0 to disable."1272 description="Refresh provider status, versions, and models in the background. Set to 0 to disable."
1319 resetAction={1273 resetAction={
1320 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (1274 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
1321 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>1275 <span
1276 inert={!canWriteSettings}
1277 className={!canWriteSettings ? "opacity-50" : undefined}
1278 >
1322 <SettingResetButton1279 <SettingResetButton
1323 label="provider health check interval"1280 label="provider health check interval"
1324 onClick={() =>1281 onClick={() =>
...@@ -1336,11 +1293,11 @@ export function EnvironmentProviderSettings({...@@ -1336,11 +1293,11 @@ export function EnvironmentProviderSettings({
1336 }1293 }
1337 control={1294 control={
1338 <div1295 <div
1339 inert={readOnly}1296 inert={!canWriteSettings}
1340 aria-disabled={readOnly || undefined}1297 aria-disabled={!canWriteSettings || undefined}
1341 className={cn(1298 className={cn(
1342 "flex shrink-0 items-center gap-2",1299 "flex shrink-0 items-center gap-2",
1343 readOnly && "opacity-50 select-none",1300 !canWriteSettings && "opacity-50 select-none",
1344 )}1301 )}
1345 >1302 >
1346 <NumberField1303 <NumberField
...@@ -1375,7 +1332,7 @@ export function EnvironmentProviderSettings({...@@ -1375,7 +1332,7 @@ export function EnvironmentProviderSettings({
1375 />1332 />
1376 </SettingsSection>1333 </SettingsSection>
13771334
1378 {isAddInstanceDialogOpen ? (1335 {isAddInstanceDialogOpen && !readOnly ? (
1379 <AddProviderInstanceDialog1336 <AddProviderInstanceDialog
1380 open1337 open
1381 environmentId={environmentId}1338 environmentId={environmentId}
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+28-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { ProcessSignalActions } from "./ProcessSignalActions";1import { ProcessSignalActions } from "./ProcessSignalActions";
2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
2import { RefreshIcon } from "~/components/ui/refresh-icon";3import { RefreshIcon } from "~/components/ui/refresh-icon";
3import {4import {
4 ActivityIcon,5 ActivityIcon,
...@@ -40,6 +41,7 @@ import {...@@ -40,6 +41,7 @@ import {
40import { cn } from "../../lib/utils";41import { cn } from "../../lib/utils";
41import { ensureLocalApi } from "../../localApi";42import { ensureLocalApi } from "../../localApi";
42import { serverEnvironment } from "../../state/server";43import { serverEnvironment } from "../../state/server";
44import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
43import { useAtomCommand } from "../../state/use-atom-command";45import { useAtomCommand } from "../../state/use-atom-command";
44import { formatRelativeTime } from "../../timestampFormat";46import { formatRelativeTime } from "../../timestampFormat";
45import { Button } from "../ui/button";47import { Button } from "../ui/button";
...@@ -520,10 +522,12 @@ function canSignalProcess(process: ResourceTelemetryProcess): boolean {...@@ -520,10 +522,12 @@ function canSignalProcess(process: ResourceTelemetryProcess): boolean {
520522
521function ProcessActions({523function ProcessActions({
522 process,524 process,
525 canMaintainEnvironment,
523 signalingKeys,526 signalingKeys,
524 onSignal,527 onSignal,
525}: {528}: {
526 process: ResourceTelemetryProcess;529 process: ResourceTelemetryProcess;
530 canMaintainEnvironment: boolean;
527 signalingKeys: ReadonlySet<string>;531 signalingKeys: ReadonlySet<string>;
528 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;532 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
529}) {533}) {
...@@ -532,16 +536,21 @@ function ProcessActions({...@@ -532,16 +536,21 @@ function ProcessActions({
532 }536 }
533 const isSignaling = signalingKeys.has(processIdentityKey(process));537 const isSignaling = signalingKeys.has(processIdentityKey(process));
534 return (538 return (
535 <ProcessSignalActions disabled={isSignaling} onSignal={(signal) => onSignal(process, signal)} />539 <ProcessSignalActions
540 disabled={!canMaintainEnvironment || isSignaling}
541 onSignal={(signal) => onSignal(process, signal)}
542 />
536 );543 );
537}544}
538545
539function ProcessTable({546function ProcessTable({
540 processes,547 processes,
548 canMaintainEnvironment,
541 signalingKeys,549 signalingKeys,
542 onSignal,550 onSignal,
543}: {551}: {
544 processes: ReadonlyArray<ResourceTelemetryProcess>;552 processes: ReadonlyArray<ResourceTelemetryProcess>;
553 canMaintainEnvironment: boolean;
545 signalingKeys: ReadonlySet<string>;554 signalingKeys: ReadonlySet<string>;
546 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;555 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
547}) {556}) {
...@@ -650,6 +659,7 @@ function ProcessTable({...@@ -650,6 +659,7 @@ function ProcessTable({
650 <td className="px-2 py-2 text-right sm:pr-4">659 <td className="px-2 py-2 text-right sm:pr-4">
651 <ProcessActions660 <ProcessActions
652 process={process}661 process={process}
662 canMaintainEnvironment={canMaintainEnvironment}
653 signalingKeys={signalingKeys}663 signalingKeys={signalingKeys}
654 onSignal={onSignal}664 onSignal={onSignal}
655 />665 />
...@@ -820,6 +830,7 @@ export function ResourceTelemetryDiagnostics({...@@ -820,6 +830,7 @@ export function ResourceTelemetryDiagnostics({
820 const [windowMs, setWindowMs] = useState(15 * 60_000);830 const [windowMs, setWindowMs] = useState(15 * 60_000);
821 const selectedWindow =831 const selectedWindow =
822 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];832 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
833 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
823 const telemetry = useResourceTelemetry(environmentId);834 const telemetry = useResourceTelemetry(environmentId);
824 const retryTelemetry = telemetry.retry;835 const retryTelemetry = telemetry.retry;
825 const history = useResourceTelemetryHistory(836 const history = useResourceTelemetryHistory(
...@@ -848,7 +859,11 @@ export function ResourceTelemetryDiagnostics({...@@ -848,7 +859,11 @@ export function ResourceTelemetryDiagnostics({
848 const signalProcess = useCallback(859 const signalProcess = useCallback(
849 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {860 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
850 const targetEnvironmentId = environmentIdRef.current;861 const targetEnvironmentId = environmentIdRef.current;
851 if (targetEnvironmentId === null) return;862 if (
863 targetEnvironmentId === null ||
864 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
865 )
866 return;
852 const identityKey = processIdentityKey(process);867 const identityKey = processIdentityKey(process);
853 if (signalingKeysRef.current.has(identityKey)) return;868 if (signalingKeysRef.current.has(identityKey)) return;
854 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);869 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
...@@ -882,7 +897,10 @@ export function ResourceTelemetryDiagnostics({...@@ -882,7 +897,10 @@ export function ResourceTelemetryDiagnostics({
882 return;897 return;
883 }898 }
884 }899 }
885 if (environmentIdRef.current !== targetEnvironmentId) {900 if (
901 environmentIdRef.current !== targetEnvironmentId ||
902 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
903 ) {
886 clearSignaling();904 clearSignaling();
887 return;905 return;
888 }906 }
...@@ -924,6 +942,12 @@ export function ResourceTelemetryDiagnostics({...@@ -924,6 +942,12 @@ export function ResourceTelemetryDiagnostics({
924 );942 );
925943
926 const retryCollector = useCallback(() => {944 const retryCollector = useCallback(() => {
945 const environmentId = environmentIdRef.current;
946 if (
947 environmentId === null ||
948 !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)
949 )
950 return;
927 setIsRetrying(true);951 setIsRetrying(true);
928 void retryTelemetry()952 void retryTelemetry()
929 .catch((error: unknown) => {953 .catch((error: unknown) => {
...@@ -1253,6 +1277,7 @@ export function ResourceTelemetryDiagnostics({...@@ -1253,6 +1277,7 @@ export function ResourceTelemetryDiagnostics({
1253 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-xs/5">1277 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-xs/5">
1254 <ProcessTable1278 <ProcessTable
1255 processes={snapshot?.processes ?? []}1279 processes={snapshot?.processes ?? []}
1280 canMaintainEnvironment={canMaintainEnvironment}
1256 signalingKeys={signalingKeys}1281 signalingKeys={signalingKeys}
1257 onSignal={signalProcess}1282 onSignal={signalProcess}
1258 />1283 />
apps/web/src/components/settings/ScheduledTasksSettings.tsx+21-6
...@@ -59,6 +59,8 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";...@@ -59,6 +59,8 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
59import { useAtomCommand } from "../../state/use-atom-command";59import { useAtomCommand } from "../../state/use-atom-command";
60import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";60import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
61import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";61import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
62import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
63import { readEnvironmentScope } from "~/state/session";
62import { useSettingsScope } from "./SettingsScopeContext";64import { useSettingsScope } from "./SettingsScopeContext";
63import {65import {
64 WEBHOOK_SIGNATURE_DEFAULTS,66 WEBHOOK_SIGNATURE_DEFAULTS,
...@@ -246,6 +248,9 @@ export function ScheduledTasksSettings(target: {...@@ -246,6 +248,9 @@ export function ScheduledTasksSettings(target: {
246 setEditor({ environmentId, task });248 setEditor({ environmentId, task });
247 }, []);249 }, []);
248 const defaultEnvironment = environment ?? connectedEnvironments[0];250 const defaultEnvironment = environment ?? connectedEnvironments[0];
251 const canCreate = useAtomValue(
252 serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null),
253 );
249 return (254 return (
250 <SettingsPageContainer>255 <SettingsPageContainer>
251 <SettingsSection256 <SettingsSection
...@@ -255,7 +260,7 @@ export function ScheduledTasksSettings(target: {...@@ -255,7 +260,7 @@ export function ScheduledTasksSettings(target: {
255 <Button260 <Button
256 size="xs"261 size="xs"
257 variant="ghost-muted"262 variant="ghost-muted"
258 disabled={!defaultEnvironment}263 disabled={!defaultEnvironment || !canCreate}
259 onClick={() =>264 onClick={() =>
260 defaultEnvironment &&265 defaultEnvironment &&
261 setEditor({ environmentId: defaultEnvironment.environmentId, task: null })266 setEditor({ environmentId: defaultEnvironment.environmentId, task: null })
...@@ -404,6 +409,9 @@ function ScheduledTaskRow({...@@ -404,6 +409,9 @@ function ScheduledTaskRow({
404 readonly task: ScheduledTask;409 readonly task: ScheduledTask;
405 readonly onEdit: () => void;410 readonly onEdit: () => void;
406}) {411}) {
412 const canOperate = useAtomValue(
413 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
414 );
407 const [busy, setBusy] = useState(false);415 const [busy, setBusy] = useState(false);
408 const [deliveriesOpen, setDeliveriesOpen] = useState(false);416 const [deliveriesOpen, setDeliveriesOpen] = useState(false);
409 const isWebhook = task.schedule.type === "webhook";417 const isWebhook = task.schedule.type === "webhook";
...@@ -417,7 +425,7 @@ function ScheduledTaskRow({...@@ -417,7 +425,7 @@ function ScheduledTaskRow({
417 label: "scheduled task delete",425 label: "scheduled task delete",
418 });426 });
419 const act = async (action: "toggle" | "run" | "delete") => {427 const act = async (action: "toggle" | "run" | "delete") => {
420 if (busy) return;428 if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
421 setBusy(true);429 setBusy(true);
422 const result =430 const result =
423 action === "toggle"431 action === "toggle"
...@@ -462,7 +470,7 @@ function ScheduledTaskRow({...@@ -462,7 +470,7 @@ function ScheduledTaskRow({
462 <div className="flex items-center gap-2">470 <div className="flex items-center gap-2">
463 <Switch471 <Switch
464 checked={task.enabled}472 checked={task.enabled}
465 disabled={busy}473 disabled={busy || !canOperate}
466 aria-label={`Enable ${task.title}`}474 aria-label={`Enable ${task.title}`}
467 onCheckedChange={() => void act("toggle")}475 onCheckedChange={() => void act("toggle")}
468 />476 />
...@@ -472,7 +480,7 @@ function ScheduledTaskRow({...@@ -472,7 +480,7 @@ function ScheduledTaskRow({
472 <Button480 <Button
473 size="icon-sm"481 size="icon-sm"
474 variant="ghost"482 variant="ghost"
475 disabled={busy}483 disabled={busy || !canOperate}
476 aria-label={`Actions for ${task.title}`}484 aria-label={`Actions for ${task.title}`}
477 />485 />
478 }486 }
...@@ -668,6 +676,9 @@ function WebhookEndpointField({...@@ -668,6 +676,9 @@ function WebhookEndpointField({
668 readonly environmentId: EnvironmentId;676 readonly environmentId: EnvironmentId;
669 readonly task: ScheduledTask | null;677 readonly task: ScheduledTask | null;
670}) {678}) {
679 const canRotate = useAtomValue(
680 serverEnvironment.rotateScheduledTaskWebhookToken.permissionAtom(environmentId),
681 );
671 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);682 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
672 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" });683 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" });
673 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {684 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
...@@ -725,7 +736,7 @@ function WebhookEndpointField({...@@ -725,7 +736,7 @@ function WebhookEndpointField({
725 size="sm"736 size="sm"
726 variant="outline"737 variant="outline"
727 type="button"738 type="button"
728 disabled={rotating}739 disabled={rotating || !canRotate}
729 onClick={() => void rotateUrl()}740 onClick={() => void rotateUrl()}
730 >741 >
731 Rotate742 Rotate
...@@ -787,6 +798,9 @@ function ScheduledTaskEditorDialog({...@@ -787,6 +798,9 @@ function ScheduledTaskEditorDialog({
787 const settings = useEnvironmentSettings(environmentId);798 const settings = useEnvironmentSettings(environmentId);
788 const providers =799 const providers =
789 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;800 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
801 const canOperate = useAtomValue(
802 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
803 );
790 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
791 label: "scheduled task upsert",805 label: "scheduled task upsert",
792 });806 });
...@@ -840,6 +854,7 @@ function ScheduledTaskEditorDialog({...@@ -840,6 +854,7 @@ function ScheduledTaskEditorDialog({
840854
841 const submit = async () => {855 const submit = async () => {
842 if (856 if (
857 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
843 submissionPending.current ||858 submissionPending.current ||
844 saving ||859 saving ||
845 editingTaskMissing ||860 editingTaskMissing ||
...@@ -1348,7 +1363,7 @@ function ScheduledTaskEditorDialog({...@@ -1348,7 +1363,7 @@ function ScheduledTaskEditorDialog({
1348 </DialogClose>1363 </DialogClose>
1349 <Button1364 <Button
1350 size="sm"1365 size="sm"
1351 disabled={saving || editingTaskMissing || !connected || !tasksQuery.data}1366 disabled={!canOperate || saving || editingTaskMissing || !connected || !tasksQuery.data}
1352 onClick={() => void submit()}1367 onClick={() => void submit()}
1353 >1368 >
1354 {draft.editingId ? "Save task" : "Create task"}1369 {draft.editingId ? "Save task" : "Create task"}
apps/web/src/components/settings/SettingsPanels.tsx+9-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { SettingsGroup } from "./SettingsGroup";1import { SettingsGroup } from "./SettingsGroup";
2import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
2import { Spinner } from "~/components/ui/spinner";3import { Spinner } from "~/components/ui/spinner";
3import { NotificationSettings } from "./NotificationSettings";4import { NotificationSettings } from "./NotificationSettings";
4import { PRIVACY_POLICY_URL } from "../../legalLinks";5import { PRIVACY_POLICY_URL } from "../../legalLinks";
...@@ -876,6 +877,7 @@ function BackgroundActivityAdvancedDialog({...@@ -876,6 +877,7 @@ function BackgroundActivityAdvancedDialog({
876 readonly open: boolean;877 readonly open: boolean;
877 readonly onOpenChange: (open: boolean) => void;878 readonly onOpenChange: (open: boolean) => void;
878}) {879}) {
880 const canWriteSettings = useScopedSettingsWriteAllowed();
879 const settings = useScopedSettings();881 const settings = useScopedSettings();
880 const updateSettings = useUpdateScopedSettings();882 const updateSettings = useUpdateScopedSettings();
881 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);883 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
...@@ -894,7 +896,7 @@ function BackgroundActivityAdvancedDialog({...@@ -894,7 +896,7 @@ function BackgroundActivityAdvancedDialog({
894 );896 );
895897
896 return (898 return (
897 <Dialog open={open} onOpenChange={onOpenChange}>899 <Dialog open={open && canWriteSettings} onOpenChange={onOpenChange}>
898 <DialogPopup className="max-w-xl">900 <DialogPopup className="max-w-xl">
899 <DialogHeader>901 <DialogHeader>
900 <DialogTitle>Background Activity</DialogTitle>902 <DialogTitle>Background Activity</DialogTitle>
...@@ -903,7 +905,10 @@ function BackgroundActivityAdvancedDialog({...@@ -903,7 +905,10 @@ function BackgroundActivityAdvancedDialog({
903 </DialogDescription>905 </DialogDescription>
904 </DialogHeader>906 </DialogHeader>
905 <DialogPanel>907 <DialogPanel>
906 <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">908 <fieldset
909 disabled={!canWriteSettings}
910 className="min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground"
911 >
907 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">912 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
908 <div className="min-w-0 space-y-1">913 <div className="min-w-0 space-y-1">
909 <div className="text-sm font-medium">Shared policy</div>914 <div className="text-sm font-medium">Shared policy</div>
...@@ -1125,11 +1130,12 @@ function BackgroundActivityAdvancedDialog({...@@ -1125,11 +1130,12 @@ function BackgroundActivityAdvancedDialog({
1125 </label>1130 </label>
1126 ))}1131 ))}
1127 </div>1132 </div>
1128 </div>1133 </fieldset>
1129 </DialogPanel>1134 </DialogPanel>
1130 <DialogFooter>1135 <DialogFooter>
1131 <Button1136 <Button
1132 variant="outline"1137 variant="outline"
1138 disabled={!canWriteSettings}
1133 onClick={() => updateSettings(resetBackgroundActivitySettings())}1139 onClick={() => updateSettings(resetBackgroundActivitySettings())}
1134 >1140 >
1135 Reset all1141 Reset all
apps/web/src/components/settings/SnapShotSettings.test.tsx+4
...@@ -25,6 +25,10 @@ vi.mock("react/compiler-runtime", async () => {...@@ -25,6 +25,10 @@ vi.mock("react/compiler-runtime", async () => {
25 return { c: reactHookHarness.useMemoCache };25 return { c: reactHookHarness.useMemoCache };
26});26});
27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));27vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("../../state/environments", () => ({
29 usePrimaryEnvironmentId: () => null,
30 useEnvironments: () => [],
31}));
28vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));32vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
29vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));33vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
30vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));34vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
apps/web/src/components/settings/settingsLayout.tsx+27-5
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { SettingsGroup } from "./SettingsGroup";1import { SettingsGroup } from "./SettingsGroup";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { usePrimaryEnvironmentId } from "../../state/environments";
4import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
2import { InfoIcon, Undo2Icon } from "lucide-react";5import { InfoIcon, Undo2Icon } from "lucide-react";
3import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";6import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
4import * as Equal from "effect/Equal";7import * as Equal from "effect/Equal";
...@@ -27,6 +30,7 @@ import { SettingsScopeSentence } from "./SettingsScopeSentence";...@@ -27,6 +30,7 @@ import { SettingsScopeSentence } from "./SettingsScopeSentence";
27import {30import {
28 isProjectScopedSettingKey,31 isProjectScopedSettingKey,
29 listProjectOverrides,32 listProjectOverrides,
33 type ProjectOverrideEntry,
30 scopedSettingsAreMixed,34 scopedSettingsAreMixed,
31 scopedSettingsSource,35 scopedSettingsSource,
32} from "./scopedSettings";36} from "./scopedSettings";
...@@ -281,6 +285,16 @@ export function SettingsRow({...@@ -281,6 +285,16 @@ export function SettingsRow({
281 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);285 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
282 const primarySettingsAvailable = usePrimarySettingsAvailable();286 const primarySettingsAvailable = usePrimarySettingsAvailable();
283 const context = useOptionalSettingsScope();287 const context = useOptionalSettingsScope();
288 const primaryEnvironmentId = usePrimaryEnvironmentId();
289 const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope);
290 const writableIds = useEnvironmentsWithScope(
291 context?.connectedEnvironments ?? [],
292 AuthSettingsWriteScope,
293 );
294 const canWriteSettings = context
295 ? context.connectedEnvironments.length > 0 &&
296 context.connectedEnvironments.every((target) => writableIds.has(target.environmentId))
297 : primaryCanWrite;
284 const clearOverrides = useClearScopedSettings();298 const clearOverrides = useClearScopedSettings();
285 const clearProjectOverrides = useClearProjectOverrides();299 const clearProjectOverrides = useClearProjectOverrides();
286 const isProjectScope =300 const isProjectScope =
...@@ -295,7 +309,8 @@ export function SettingsRow({...@@ -295,7 +309,8 @@ export function SettingsRow({
295 context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;309 context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;
296 const unavailable =310 const unavailable =
297 serverScoped &&311 serverScoped &&
298 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable);312 (!canWriteSettings ||
313 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable));
299 const inheritedFrom =314 const inheritedFrom =
300 source === "environment" && context?.scope.environmentIds.length === 1315 source === "environment" && context?.scope.environmentIds.length === 1
301 ? (context.environments.find(316 ? (context.environments.find(
...@@ -375,9 +390,11 @@ export function SettingsRow({...@@ -375,9 +390,11 @@ export function SettingsRow({
375 const renderedControl =390 const renderedControl =
376 unavailable && control391 unavailable && control
377 ? inertControl(392 ? inertControl(
378 context393 !canWriteSettings
379 ? "Reconnect the selected environment to change this setting."394 ? "This connection does not have permission to change environment settings."
380 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,395 : context
396 ? "Reconnect the selected environment to change this setting."
397 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
381 )398 )
382 : environmentWide && control399 : environmentWide && control
383 ? inertControl("Environment-wide setting. Select an environment to change it.")400 ? inertControl("Environment-wide setting. Select an environment to change it.")
...@@ -415,7 +432,12 @@ export function SettingsRow({...@@ -415,7 +432,12 @@ export function SettingsRow({
415 environments={context.connectedEnvironments}432 environments={context.connectedEnvironments}
416 keys={settingKeys}433 keys={settingKeys}
417 overridingProjects={overridingProjects}434 overridingProjects={overridingProjects}
418 onClearOverrides={(entries) => clearProjectOverrides(entries, scopedKeys)}435 {...(canWriteSettings
436 ? {
437 onClearOverrides: (entries: readonly ProjectOverrideEntry[]) =>
438 clearProjectOverrides(entries, scopedKeys),
439 }
440 : {})}
419 />441 />
420 ) : null;442 ) : null;
421 const renderedStatus = status;443 const renderedStatus = status;
apps/web/src/components/settings/settingsSearch.ts-1
...@@ -813,7 +813,6 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -813,7 +813,6 @@ export const SETTINGS_SEARCH_ITEMS = [
813 to: "/settings/connections",813 to: "/settings/connections",
814 targetId: "connections-environment",814 targetId: "connections-environment",
815 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],815 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],
816 localBackendManagementOnly: true,
817 },816 },
818 {817 {
819 id: "local-environment",818 id: "local-environment",
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+7-11
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
33
4import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";4import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
5import { hasCloudPublicConfig } from "~/cloud/publicConfig";5import { hasCloudPublicConfig } from "~/cloud/publicConfig";
6import { isElectron } from "~/env";6import { isElectron } from "~/env";
7import { isLocalEnvironmentDisabled } from "~/localEnvironment";7import { isLocalEnvironmentDisabled } from "~/localEnvironment";
8import { desktopWslStateAtom } from "~/state/desktopWslState";8import { desktopWslStateAtom } from "~/state/desktopWslState";
9import { useEnvironments } from "~/state/environments";9import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
10import { useEnvironmentQuery } from "~/state/query";10import { useEnvironmentQuery } from "~/state/query";
11import { usePrimarySessionState } from "~/environments/primary";11import { useEnvironmentScope } from "~/state/session";
12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
14import type { SettingsScopeSearch } from "./settingsScope";14import type { SettingsScopeSearch } from "./settingsScope";
...@@ -19,21 +19,17 @@ import {...@@ -19,21 +19,17 @@ import {
1919
20export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) {20export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) {
21 const { environments } = useEnvironments();21 const { environments } = useEnvironments();
22 const primarySessionState = usePrimarySessionState();22 const primaryEnvironmentId = usePrimaryEnvironmentId();
23 const localEnvironmentDisabled = isLocalEnvironmentDisabled();23 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
24 const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
25 const canManageLocalBackend = !localEnvironmentDisabled && canMaintain;
24 const desktopWsl = useEnvironmentQuery(26 const desktopWsl = useEnvironmentQuery(
25 isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null,27 isElectron && canManageLocalBackend ? desktopWslStateAtom : null,
26 );28 );
27 const cloudLinkState = usePrimaryCloudLinkState().data;29 const cloudLinkState = usePrimaryCloudLinkState().data;
28 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.30 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
29 const managedTunnelActive =31 const managedTunnelActive =
30 cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false;32 cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false;
31 const canManageLocalBackend =
32 !localEnvironmentDisabled &&
33 (isElectron ||
34 ((primarySessionState.data?.authenticated &&
35 primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??
36 false));
3733
38 return useMemo(34 return useMemo(
39 () =>35 () =>
apps/web/src/components/settings/useHostConnectionChecks.ts+5
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
1import { useRef, useState } from "react";3import { useRef, useState } from "react";
2import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
3import type { SshDeviceHostConfig } from "@t3tools/contracts";5import type { SshDeviceHostConfig } from "@t3tools/contracts";
...@@ -25,6 +27,9 @@ export function useHostConnectionChecks(targets: ReadonlyArray<DeviceHostCheckTa...@@ -25,6 +27,9 @@ export function useHostConnectionChecks(targets: ReadonlyArray<DeviceHostCheckTa
25 targets,27 targets,
26 host,28 host,
27 async (environmentId, input) => {29 async (environmentId, input) => {
30 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
31 throw new Error("This connection cannot test device hosts.");
32 }
28 const result = await test({ environmentId, input });33 const result = await test({ environmentId, input });
29 if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause));34 if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause));
30 return result.value;35 return result.value;
apps/web/src/components/settings/useProjectScriptSettings.ts+18
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
1import {3import {
2 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
3 mapAtomCommandResult,5 mapAtomCommandResult,
...@@ -80,6 +82,22 @@ export function useProjectScriptSettings(...@@ -80,6 +82,22 @@ export function useProjectScriptSettings(
80 toastManager.add({ type: "error", title: "Actions not saved", description: message });82 toastManager.add({ type: "error", title: "Actions not saved", description: message });
81 return AsyncResult.failure(Cause.fail(new Error(message)));83 return AsyncResult.failure(Cause.fail(new Error(message)));
82 }84 }
85 if (
86 targets.some(
87 ({ environmentId }) => !readEnvironmentScope(environmentId, AuthSettingsWriteScope),
88 )
89 ) {
90 return reportScriptFailure(
91 AsyncResult.failure(
92 Cause.fail(
93 new EnvironmentAuthorizationError({
94 requiredScope: AuthSettingsWriteScope,
95 message: "This connection cannot change environment settings.",
96 }),
97 ),
98 ),
99 );
100 }
83 savingRef.current = true;101 savingRef.current = true;
84 setSaving(true);102 setSaving(true);
85 try {103 try {
apps/web/src/components/settings/useScopedSettings.ts+44-12
...@@ -1,3 +1,12 @@...@@ -1,3 +1,12 @@
1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { useEnvironmentsWithScope } from "../../state/session";
3import {
4 requiredScopesForServerSettingsPatch,
5 EnvironmentAuthorizationError,
6} from "@t3tools/contracts";
7import { readEnvironmentScope } from "../../state/session";
8import * as Cause from "effect/Cause";
9import { AsyncResult } from "effect/reactivity";
1import {10import {
2 DEFAULT_SERVER_SETTINGS,11 DEFAULT_SERVER_SETTINGS,
3 type ProjectScopedServerSettingKey,12 type ProjectScopedServerSettingKey,
...@@ -63,19 +72,33 @@ function useRunScopedPlan() {...@@ -63,19 +72,33 @@ function useRunScopedPlan() {
63 });72 });
64 return;73 return;
65 }74 }
66 void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then(75 void persistScopedSettingsPatch(
67 ({ failedEnvironments, savedEnvironmentCount }) => {76 plan,
68 if (failedEnvironments.length === 0) return;77 async (request) => {
69 toastManager.add({78 const missing = requiredScopesForServerSettingsPatch(request.input.patch).find(
70 type: "error",79 (scope) => !readEnvironmentScope(request.environmentId, scope),
71 title:80 );
72 savedEnvironmentCount > 081 if (missing)
73 ? "Setting saved on some environments"82 return AsyncResult.failure(
74 : "Setting not saved",83 Cause.fail(
75 description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,84 new EnvironmentAuthorizationError({
76 });85 requiredScope: missing,
86 message: "This connection cannot change these settings.",
87 }),
88 ),
89 );
90 return persistServer(request);
77 },91 },
78 );92 persistClientSettingsPatch,
93 ).then(({ failedEnvironments, savedEnvironmentCount }) => {
94 if (failedEnvironments.length === 0) return;
95 toastManager.add({
96 type: "error",
97 title:
98 savedEnvironmentCount > 0 ? "Setting saved on some environments" : "Setting not saved",
99 description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,
100 });
101 });
79 },102 },
80 [persistServer],103 [persistServer],
81 );104 );
...@@ -119,3 +142,12 @@ export function useClearProjectOverrides() {...@@ -119,3 +142,12 @@ export function useClearProjectOverrides() {
119 [context, run],142 [context, run],
120 );143 );
121}144}
145
146export function useScopedSettingsWriteAllowed() {
147 const { connectedEnvironments } = useSettingsScope();
148 const writable = useEnvironmentsWithScope(connectedEnvironments, AuthSettingsWriteScope);
149 return (
150 connectedEnvironments.length > 0 &&
151 connectedEnvironments.every((target) => writable.has(target.environmentId))
152 );
153}
apps/web/src/components/usage/UsageLimits.tsx+23-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 AuthProvidersManageScope,
2 type EnvironmentId,3 type EnvironmentId,
3 type ProviderConsumeResetCreditOutcome,4 type ProviderConsumeResetCreditOutcome,
4 ProviderConsumeResetCreditInput,5 ProviderConsumeResetCreditInput,
...@@ -21,6 +22,7 @@ import { Fragment, type ReactNode, useState } from "react";...@@ -21,6 +22,7 @@ import { Fragment, type ReactNode, useState } from "react";
2122
22import { usePrimarySettings } from "../../hooks/useSettings";23import { usePrimarySettings } from "../../hooks/useSettings";
23import { environmentPresentations } from "../../state/presentation";24import { environmentPresentations } from "../../state/presentation";
25import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
24import { serverEnvironment } from "../../state/server";26import { serverEnvironment } from "../../state/server";
25import { useAtomCommand } from "../../state/use-atom-command";27import { useAtomCommand } from "../../state/use-atom-command";
26import { formatUpcomingTimestamp } from "../../timestampFormat";28import { formatUpcomingTimestamp } from "../../timestampFormat";
...@@ -206,6 +208,7 @@ export function useResetCredit(...@@ -206,6 +208,7 @@ export function useResetCredit(
206 environmentId: EnvironmentId,208 environmentId: EnvironmentId,
207 input: ProviderConsumeResetCreditInput,209 input: ProviderConsumeResetCreditInput,
208) {210) {
211 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
209 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });212 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
210 const [confirming, setConfirming] = useState(false);213 const [confirming, setConfirming] = useState(false);
211 const [busy, setBusy] = useState(false);214 const [busy, setBusy] = useState(false);
...@@ -213,6 +216,7 @@ export function useResetCredit(...@@ -213,6 +216,7 @@ export function useResetCredit(
213216
214 const redeem = async () => {217 const redeem = async () => {
215 setConfirming(false);218 setConfirming(false);
219 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
216 setBusy(true);220 setBusy(true);
217 setStatus(null);221 setStatus(null);
218 const result = await consume({ environmentId, input });222 const result = await consume({ environmentId, input });
...@@ -228,7 +232,7 @@ export function useResetCredit(...@@ -228,7 +232,7 @@ export function useResetCredit(
228 );232 );
229 };233 };
230234
231 return { confirming, setConfirming, busy, status, redeem };235 return { canManageProviders, confirming, setConfirming, busy, status, redeem };
232}236}
233237
234/**238/**
...@@ -241,10 +245,12 @@ export function ResetCreditDialog({...@@ -241,10 +245,12 @@ export function ResetCreditDialog({
241 open,245 open,
242 onOpenChange,246 onOpenChange,
243 onConfirm,247 onConfirm,
248 disabled = false,
244}: {249}: {
245 readonly open: boolean;250 readonly open: boolean;
246 readonly onOpenChange: (open: boolean) => void;251 readonly onOpenChange: (open: boolean) => void;
247 readonly onConfirm: () => void;252 readonly onConfirm: () => void;
253 readonly disabled?: boolean;
248}) {254}) {
249 return (255 return (
250 <AlertDialog open={open} onOpenChange={onOpenChange}>256 <AlertDialog open={open} onOpenChange={onOpenChange}>
...@@ -258,7 +264,9 @@ export function ResetCreditDialog({...@@ -258,7 +264,9 @@ export function ResetCreditDialog({
258 </AlertDialogHeader>264 </AlertDialogHeader>
259 <AlertDialogFooter>265 <AlertDialogFooter>
260 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>266 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
261 <Button onClick={onConfirm}>Use credit</Button>267 <Button disabled={disabled} onClick={onConfirm}>
268 Use credit
269 </Button>
262 </AlertDialogFooter>270 </AlertDialogFooter>
263 </AlertDialogPopup>271 </AlertDialogPopup>
264 </AlertDialog>272 </AlertDialog>
...@@ -294,13 +302,23 @@ export function ResetCredits({...@@ -294,13 +302,23 @@ export function ResetCredits({
294 readonly credits: ServerProviderResetCredits;302 readonly credits: ServerProviderResetCredits;
295 readonly now: number;303 readonly now: number;
296}) {304}) {
297 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input);305 const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit(
306 environmentId,
307 input,
308 );
298 if (credits.availableCount === 0 && status === null) return null;309 if (credits.availableCount === 0 && status === null) return null;
299 return (310 return (
300 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">311 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
301 <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span>312 <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span>
302 {credits.availableCount > 0 ? (313 {credits.availableCount > 0 ? (
303 <Button size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(true)}>314 <Button
315 size="xs"
316 variant="outline"
317 disabled={busy || !canManageProviders}
318 onClick={() => {
319 if (readEnvironmentScope(environmentId, AuthProvidersManageScope)) setConfirming(true);
320 }}
321 >
304 {busy ? "Using…" : "Use reset"}322 {busy ? "Using…" : "Use reset"}
305 </Button>323 </Button>
306 ) : null}324 ) : null}
...@@ -309,6 +327,7 @@ export function ResetCredits({...@@ -309,6 +327,7 @@ export function ResetCredits({
309 open={confirming}327 open={confirming}
310 onOpenChange={setConfirming}328 onOpenChange={setConfirming}
311 onConfirm={() => void redeem()}329 onConfirm={() => void redeem()}
330 disabled={!canManageProviders}
312 />331 />
313 </div>332 </div>
314 );333 );
apps/web/src/components/usage/UsageLimitsPooled.tsx+2-1
...@@ -204,7 +204,7 @@ function SegmentPopover({...@@ -204,7 +204,7 @@ function SegmentPopover({
204 <Button204 <Button
205 size="xs"205 size="xs"
206 variant="outline"206 variant="outline"
207 disabled={redeem.busy}207 disabled={redeem.busy || !redeem.canManageProviders}
208 className="ms-auto"208 className="ms-auto"
209 onClick={onRedeem}209 onClick={onRedeem}
210 >210 >
...@@ -429,6 +429,7 @@ function RedeemableSegmentPopup({...@@ -429,6 +429,7 @@ function RedeemableSegmentPopup({
429 open={redeem.confirming}429 open={redeem.confirming}
430 onOpenChange={redeem.setConfirming}430 onOpenChange={redeem.setConfirming}
431 onConfirm={() => void redeem.redeem()}431 onConfirm={() => void redeem.redeem()}
432 disabled={!redeem.canManageProviders}
432 />433 />
433 {/* The popover closed before the confirm, so the outcome needs a home outside it. */}434 {/* The popover closed before the confirm, so the outcome needs a home outside it. */}
434 {redeem.status ? (435 {redeem.status ? (
apps/web/src/components/usage/UsagePriceOverrides.tsx+17-17
...@@ -1,21 +1,16 @@...@@ -1,21 +1,16 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";5import { AsyncResult, Atom } from "effect/reactivity";
6import { useRef, useState } from "react";6import { useRef, useState } from "react";
77
8import { isElectron } from "../../env";
9import { cn } from "../../lib/utils";8import { cn } from "../../lib/utils";
10import { environmentPresentations } from "../../state/presentation";9import { environmentPresentations } from "../../state/presentation";
11import { serverEnvironment } from "../../state/server";10import { serverEnvironment } from "../../state/server";
12import { environmentSession } from "../../state/session";11import { environmentSession, readEnvironmentScope } from "../../state/session";
13import { useAtomCommand } from "../../state/use-atom-command";12import { useAtomCommand } from "../../state/use-atom-command";
14import type { EnvironmentUsageStatus } from "../../state/usage";13import type { EnvironmentUsageStatus } from "../../state/usage";
15import {
16 resolvePrimaryOperateAccess,
17 resolveRemoteOperateAccess,
18} from "../settings/ProviderSettingsPanel.logic";
19import { Button } from "../ui/button";14import { Button } from "../ui/button";
20import {15import {
21 Dialog,16 Dialog,
...@@ -51,15 +46,17 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>...@@ -51,15 +46,17 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
51 [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => {46 [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => {
52 const settings = get(serverEnvironment.settingsValueAtom(environmentId));47 const settings = get(serverEnvironment.settingsValueAtom(environmentId));
53 const session = get(environmentSession.sessionStateAtom(environmentId));48 const session = get(environmentSession.sessionStateAtom(environmentId));
54 const sessionAccess = {49 const sessionData = Option.getOrNull(AsyncResult.value(session));
55 session: Option.getOrNull(AsyncResult.value(session)),50 const access =
56 isPending: session.waiting,51 session._tag === "Failure"
57 hasError: session._tag === "Failure",52 ? "denied"
58 };53 : sessionData === null
59 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";54 ? session.waiting
60 const access = isPrimary55 ? "pending"
61 ? resolvePrimaryOperateAccess({ ...sessionAccess, isPrimary, hasDesktopBridge: isElectron })56 : "denied"
62 : resolveRemoteOperateAccess(sessionAccess);57 : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope)
58 ? "granted"
59 : "denied";
63 return {60 return {
64 environmentId,61 environmentId,
65 label: environment.entry.target.label,62 label: environment.entry.target.label,
...@@ -262,7 +259,10 @@ export function UsagePriceOverrides({...@@ -262,7 +259,10 @@ export function UsagePriceOverrides({
262 target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null,259 target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null,
263 })),260 })),
264 changes: new Map([...changes].map(([id, plan]) => [id, plan.changes])),261 changes: new Map([...changes].map(([id, plan]) => [id, plan.changes])),
265 write: updateSettings,262 write: async (input) =>
263 readEnvironmentScope(input.environmentId, AuthSettingsWriteScope)
264 ? updateSettings(input)
265 : { _tag: "Failure" },
266 onResult: (environmentId, result) => {266 onResult: (environmentId, result) => {
267 if (result.status === "failed") failed = true;267 if (result.status === "failed") failed = true;
268 setAttempt((previous) =>268 setAttempt((previous) =>
apps/web/src/editorPreferences.test.ts created+88
...@@ -0,0 +1,88 @@
1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import { assert, beforeEach, expect, it, vi } from "vite-plus/test";
9
10const state = vi.hoisted(() => ({
11 allowed: new Set<string>(),
12 run: vi.fn(),
13 getPreference: vi.fn(),
14 setPreference: vi.fn(),
15}));
16
17vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
18vi.mock("./state/shell", () => ({ shellEnvironment: { openInEditor: "openInEditor" } }));
19vi.mock("./state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
20vi.mock("./state/session", () => ({
21 readEnvironmentScope: (environmentId: string, scope: string) =>
22 scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId),
23}));
24vi.mock("./hooks/useLocalStorage", () => ({
25 getLocalStorageItem: state.getPreference,
26 setLocalStorageItem: state.setPreference,
27 useLocalStorage: vi.fn(),
28}));
29
30import { useOpenInPreferredEditor } from "./editorPreferences";
31
32const primary = EnvironmentId.make("primary");
33const secondary = EnvironmentId.make("secondary");
34
35beforeEach(() => {
36 state.allowed.clear();
37 state.run.mockReset().mockResolvedValue(AsyncResult.success(undefined));
38 state.getPreference.mockReset().mockReturnValue(null);
39 state.setPreference.mockReset();
40});
41
42it("does not use another environment's grant or change preferences when denied", async () => {
43 state.allowed.add(primary);
44 const open = useOpenInPreferredEditor(secondary, ["vscode"]);
45 const result = await open("/work/readme.md");
46 assert(result._tag === "Failure");
47 const error = Cause.squash(result.cause);
48 expect(error).toBeInstanceOf(EnvironmentAuthorizationError);
49 expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
50 expect(state.run).not.toHaveBeenCalled();
51 expect(state.getPreference).not.toHaveBeenCalled();
52 expect(state.setPreference).not.toHaveBeenCalled();
53});
54
55it("checks revocation and regrant when a retained editor callback is invoked", async () => {
56 state.allowed.add(secondary);
57 const open = useOpenInPreferredEditor(secondary, ["vscode"]);
58 state.allowed.delete(secondary);
59 expect((await open("/work/readme.md:5"))._tag).toBe("Failure");
60 expect(state.run).not.toHaveBeenCalled();
61
62 state.allowed.add(secondary);
63 expect(await open("/work/readme.md:5")).toMatchObject({ _tag: "Success", value: "vscode" });
64 expect(state.run).toHaveBeenCalledExactlyOnceWith({
65 environmentId: secondary,
66 input: { cwd: "/work/readme.md:5", editor: "vscode" },
67 });
68 expect(state.setPreference).toHaveBeenCalledTimes(1);
69});
70
71it("keeps the stored available editor for an authorized launch", async () => {
72 state.allowed.add(secondary);
73 state.getPreference.mockReturnValue("cursor");
74 const open = useOpenInPreferredEditor(secondary, ["vscode", "cursor"]);
75 expect(await open("/work")).toMatchObject({ _tag: "Success", value: "cursor" });
76 expect(state.run).toHaveBeenCalledExactlyOnceWith({
77 environmentId: secondary,
78 input: { cwd: "/work", editor: "cursor" },
79 });
80 expect(state.setPreference).not.toHaveBeenCalled();
81});
82
83it("returns the existing failures for a missing environment or editor", async () => {
84 state.allowed.add(secondary);
85 expect((await useOpenInPreferredEditor(null, ["vscode"])("/work"))._tag).toBe("Failure");
86 expect((await useOpenInPreferredEditor(secondary, [])("/work"))._tag).toBe("Failure");
87 expect(state.run).not.toHaveBeenCalled();
88});
apps/web/src/editorPreferences.ts+20-4
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts";1import {
2 AuthOrchestrationOperateScope,
3 EDITORS,
4 EditorId,
5 EnvironmentAuthorizationError,
6 EnvironmentId,
7} from "@t3tools/contracts";
2import {8import {
3 mapAtomCommandResult,9 mapAtomCommandResult,
4 type AtomCommandFailure,10 type AtomCommandFailure,
...@@ -11,6 +17,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hoo...@@ -11,6 +17,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hoo
11import { useCallback, useMemo } from "react";17import { useCallback, useMemo } from "react";
12import { shellEnvironment } from "./state/shell";18import { shellEnvironment } from "./state/shell";
13import { useAtomCommand } from "./state/use-atom-command";19import { useAtomCommand } from "./state/use-atom-command";
20import { readEnvironmentScope } from "./state/session";
1421
15const LAST_EDITOR_KEY = "t3code:last-editor";22const LAST_EDITOR_KEY = "t3code:last-editor";
1623
...@@ -49,9 +56,7 @@ export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {...@@ -49,9 +56,7 @@ export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {
49 return [effectiveEditor, setLastEditor] as const;56 return [effectiveEditor, setLastEditor] as const;
50}57}
5158
52export function resolveAndPersistPreferredEditor(59function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null {
53 availableEditors: readonly EditorId[],
54): EditorId | null {
55 const availableEditorIds = new Set(availableEditors);60 const availableEditorIds = new Set(availableEditors);
56 const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId);61 const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId);
57 if (stored && availableEditorIds.has(stored)) return stored;62 if (stored && availableEditorIds.has(stored)) return stored;
...@@ -78,6 +83,7 @@ export function useOpenInPreferredEditor(...@@ -78,6 +83,7 @@ export function useOpenInPreferredEditor(
78 | OpenInEditorError83 | OpenInEditorError
79 | PreferredEditorEnvironmentRequiredError84 | PreferredEditorEnvironmentRequiredError
80 | PreferredEditorUnavailableError85 | PreferredEditorUnavailableError
86 | EnvironmentAuthorizationError
81 >87 >
82 > => {88 > => {
83 if (environmentId === null) {89 if (environmentId === null) {
...@@ -89,6 +95,16 @@ export function useOpenInPreferredEditor(...@@ -89,6 +95,16 @@ export function useOpenInPreferredEditor(
89 ),95 ),
90 );96 );
91 }97 }
98 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
99 return AsyncResult.failure(
100 Cause.fail(
101 new EnvironmentAuthorizationError({
102 requiredScope: AuthOrchestrationOperateScope,
103 message: "This connection cannot open an editor on this environment.",
104 }),
105 ),
106 );
107 }
92 const editor = resolveAndPersistPreferredEditor(availableEditors);108 const editor = resolveAndPersistPreferredEditor(availableEditors);
93 if (!editor) {109 if (!editor) {
94 return AsyncResult.failure(110 return AsyncResult.failure(
apps/web/src/hooks/useSettings.sync.test.tsx created+201
...@@ -0,0 +1,201 @@
1import { RegistryContext } from "@effect/atom-react";
2import {
3 AuthSettingsWriteScope,
4 DEFAULT_SERVER_SETTINGS,
5 EnvironmentId,
6 type AuthEnvironmentScope,
7 type AuthSessionState,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
12import { act } from "react";
13import { create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15
16type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
17const state = vi.hoisted(() => ({
18 registry: null as AtomRegistry.AtomRegistry | null,
19 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
20 environments: [] as Array<{
21 environmentId: EnvironmentId;
22 label: string;
23 connection: { phase: "connected" | "disconnected" };
24 serverConfig: {
25 environment: { capabilities: { threadAutoSettlement: boolean } };
26 settings: typeof DEFAULT_SERVER_SETTINGS;
27 };
28 }>,
29 persist: vi.fn(),
30 toast: vi.fn(),
31}));
32
33vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined }));
34vi.mock("@t3tools/client-runtime/state/session", () => ({
35 createEnvironmentSessionAtoms: () => ({
36 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
37 }),
38}));
39vi.mock("~/rpc/atomRegistry", () => ({
40 get appAtomRegistry() {
41 return state.registry;
42 },
43}));
44vi.mock("~/state/environments", () => ({
45 useEnvironments: () => ({ environments: state.environments }),
46 usePrimaryEnvironment: () =>
47 state.environments.find((environment) => environment.environmentId === primaryId) ?? null,
48}));
49vi.mock("~/state/server", () => ({
50 serverEnvironment: { updateSettings: Symbol("updateSettings") },
51 primaryServerSettingsAtom: undefined,
52}));
53vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.persist }));
54vi.mock("~/components/ui/toast", () => ({ toastManager: { add: state.toast } }));
55vi.mock("~/themePalette", () => ({}));
56vi.mock("./useTheme", () => ({}));
57
58import { useUpdatePrimarySettings } from "./useSettings";
59
60const primaryId = EnvironmentId.make("primary");
61const remoteId = EnvironmentId.make("remote");
62const patch = { sidebarAutoSettleOnMerge: false } satisfies ServerSettingsPatch;
63const session = (scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState => ({
64 authenticated: true,
65 scopes,
66 auth: {
67 policy: "remote-reachable",
68 bootstrapMethods: ["one-time-token"],
69 sessionMethods: ["bearer-access-token"],
70 sessionCookieName: "t3_session",
71 },
72});
73let renderer: ReactTestRenderer | undefined;
74
75function SettingsEditor() {
76 const updateSettings = useUpdatePrimarySettings();
77 return <button onClick={() => updateSettings(patch)}>Save shared setting</button>;
78}
79
80function saveSharedSettings() {
81 renderer!.root.findByType("button").props.onClick();
82}
83
84async function mountEditor() {
85 await act(() => {
86 renderer = create(
87 <RegistryContext.Provider value={state.registry!}>
88 <SettingsEditor />
89 </RegistryContext.Provider>,
90 );
91 });
92}
93
94beforeEach(() => {
95 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
96 state.registry = AtomRegistry.make();
97 state.sessions.clear();
98 state.sessions.set(
99 primaryId,
100 Atom.make<SessionResult>(AsyncResult.success(session([AuthSettingsWriteScope]))),
101 );
102 state.sessions.set(remoteId, Atom.make<SessionResult>(AsyncResult.initial()));
103 state.environments = [primaryId, remoteId].map((environmentId) => ({
104 environmentId,
105 label: environmentId,
106 connection: { phase: "connected" },
107 serverConfig: {
108 environment: { capabilities: { threadAutoSettlement: true } },
109 settings: DEFAULT_SERVER_SETTINGS,
110 },
111 }));
112 state.persist.mockReset();
113 state.persist.mockResolvedValue(AsyncResult.success(DEFAULT_SERVER_SETTINGS));
114 state.toast.mockReset();
115});
116
117afterEach(async () => {
118 await act(() => renderer?.unmount());
119 renderer = undefined;
120 state.registry?.dispose();
121 vi.unstubAllGlobals();
122});
123
124describe("shared settings writes", () => {
125 it("dispatches to the primary and connected remote before the remote grant finishes loading", async () => {
126 await mountEditor();
127 saveSharedSettings();
128
129 expect(state.persist.mock.calls).toEqual([
130 [{ environmentId: primaryId, input: { patch } }],
131 [{ environmentId: remoteId, input: { patch } }],
132 ]);
133 expect(state.registry!.get(state.sessions.get(remoteId)!)).toMatchObject({ _tag: "Initial" });
134 });
135
136 it.each([
137 ["denied", () => AsyncResult.success(session([]))],
138 ["denied while refreshing", () => AsyncResult.waiting(AsyncResult.success(session([])))],
139 [
140 "failed",
141 () => AsyncResult.failure<AuthSessionState, Error>(Cause.fail(new Error("session rejected"))),
142 ],
143 ] as const)("skips a remote whose grant is %s", async (_label, result) => {
144 state.registry!.set(state.sessions.get(remoteId)!, result());
145 await mountEditor();
146 saveSharedSettings();
147
148 expect(state.persist).toHaveBeenCalledExactlyOnceWith({
149 environmentId: primaryId,
150 input: { patch },
151 });
152 });
153
154 it.each(["disconnected", "unsupported"] as const)(
155 "skips a %s remote even with a cold grant",
156 async (condition) => {
157 const remote = state.environments[1]!;
158 if (condition === "disconnected") remote.connection.phase = "disconnected";
159 else remote.serverConfig.environment.capabilities.threadAutoSettlement = false;
160 await mountEditor();
161 saveSharedSettings();
162
163 expect(state.persist).toHaveBeenCalledExactlyOnceWith({
164 environmentId: primaryId,
165 input: { patch },
166 });
167 },
168 );
169
170 it.each(["primary", "hosted"] as const)(
171 "rechecks a cold remote grant that resolves to denied after the %s handler renders",
172 async (mode) => {
173 if (mode === "hosted") {
174 state.environments = state.environments.filter(
175 (environment) => environment.environmentId !== primaryId,
176 );
177 }
178 await mountEditor();
179 const previousUpdate = renderer!.root.findByType("button").props.onClick as () => void;
180 await act(() => {
181 state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([])));
182 });
183 previousUpdate();
184
185 if (mode === "primary") {
186 expect(state.persist).toHaveBeenCalledExactlyOnceWith({
187 environmentId: primaryId,
188 input: { patch },
189 });
190 expect(state.toast).not.toHaveBeenCalled();
191 } else {
192 expect(state.persist).not.toHaveBeenCalled();
193 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
194 type: "warning",
195 title: "Setting not saved",
196 description: "This connection does not have permission to change these settings.",
197 });
198 }
199 },
200 );
201});
apps/web/src/hooks/useSettings.ts+65-6
...@@ -13,6 +13,8 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";...@@ -13,6 +13,8 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { useAtomValue } from "@effect/atom-react";13import { useAtomValue } from "@effect/atom-react";
14import {14import {
15 DEFAULT_SERVER_SETTINGS,15 DEFAULT_SERVER_SETTINGS,
16 AuthSettingsWriteScope,
17 requiredScopesForServerSettingsPatch,
16 type EnvironmentId,18 type EnvironmentId,
17 type ProviderInstanceMutation,19 type ProviderInstanceMutation,
18 ServerSettings,20 ServerSettings,
...@@ -40,12 +42,16 @@ import {...@@ -40,12 +42,16 @@ import {
40 themeAllowsSidebarArtwork,42 themeAllowsSidebarArtwork,
41} from "~/themePalette";43} from "~/themePalette";
42import * as Struct from "effect/Struct";44import * as Struct from "effect/Struct";
45import * as Option from "effect/Option";
46import { AsyncResult, Atom } from "effect/reactivity";
43import { toastManager } from "~/components/ui/toast";47import { toastManager } from "~/components/ui/toast";
44import { isHostedStaticApp } from "~/hostedPairing";48import { isHostedStaticApp } from "~/hostedPairing";
45import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";49import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
46import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";50import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
47import { useAtomCommand } from "~/state/use-atom-command";51import { useAtomCommand } from "~/state/use-atom-command";
48import { useTheme } from "./useTheme";52import { useTheme } from "./useTheme";
53import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session";
54import { appAtomRegistry } from "~/rpc/atomRegistry";
4955
50const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";56const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
5157
...@@ -425,6 +431,31 @@ export function usePrimarySettingsAvailable(): boolean {...@@ -425,6 +431,31 @@ export function usePrimarySettingsAvailable(): boolean {
425 return primaryEnvironment !== null || !isHostedStaticApp();431 return primaryEnvironment !== null || !isHostedStaticApp();
426}432}
427433
434/** Connected sync targets, excluding grants already known to forbid settings writes. */
435function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray<EnvironmentId> {
436 const { environments } = useEnvironments();
437 const writableTargetsAtom = useMemo(
438 () =>
439 Atom.make((get) =>
440 environments.filter(supportsSharedSettingsSync).flatMap((environment) => {
441 const result = get(environmentSession.sessionStateAtom(environment.environmentId));
442 // A cold grant must not drop a shared edit. The server authorizes the
443 // write; mismatch suggestions still wait for a confirmed grant.
444 if (includePending && result._tag === "Initial") {
445 return [environment.environmentId];
446 }
447 const session =
448 result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result));
449 return session?.authenticated && session.scopes?.includes(AuthSettingsWriteScope)
450 ? [environment.environmentId]
451 : [];
452 }),
453 ),
454 [environments, includePending],
455 );
456 return useAtomValue(writableTargetsAtom);
457}
458
428/**459/**
429 * Returns an updater that routes each key to the correct backing store.460 * Returns an updater that routes each key to the correct backing store.
430 *461 *
...@@ -435,16 +466,31 @@ export function usePrimarySettingsAvailable(): boolean {...@@ -435,16 +466,31 @@ export function usePrimarySettingsAvailable(): boolean {
435 * through client persistence.466 * through client persistence.
436 */467 */
437function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {468function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
469 // Mount this session even on pages without a visible permission-gated control.
470 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
438 const persistServerSettings = useAtomCommand(471 const persistServerSettings = useAtomCommand(
439 serverEnvironment.updateSettings,472 serverEnvironment.updateSettings,
440 "server settings update",473 "server settings update",
441 );474 );
442 const { environments } = useEnvironments();475 const { environments } = useEnvironments();
476 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
443 const updateSettings = useCallback(477 const updateSettings = useCallback(
444 (patch: UnifiedSettingsPatch) => {478 (patch: UnifiedSettingsPatch) => {
445 const { serverPatch, clientPatch } = splitPatch(patch);479 const { serverPatch, clientPatch } = splitPatch(patch);
446480
447 if (Object.keys(serverPatch).length > 0) {481 const canWriteServerPatch =
482 environmentId === null ||
483 requiredScopesForServerSettingsPatch(serverPatch).every((scope) =>
484 readEnvironmentScope(environmentId, scope),
485 );
486 if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) {
487 toastManager.add({
488 type: "warning",
489 title: "Setting not saved",
490 description: "This connection does not have permission to change these settings.",
491 });
492 }
493 if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) {
448 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);494 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
449 // Dropping the write silently leaves the control looking saved.495 // Dropping the write silently leaves the control looking saved.
450 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>496 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
...@@ -467,13 +513,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -467,13 +513,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
467 const sourceSettings = environments.find(513 const sourceSettings = environments.find(
468 (target) => target.environmentId === environmentId,514 (target) => target.environmentId === environmentId,
469 )?.serverConfig?.settings;515 )?.serverConfig?.settings;
470 const targets = new Set(516 const targets = new Set(sharedSettingsSyncTargetIds);
471 environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),
472 );
473 if (environmentId) {517 if (environmentId) {
474 targets.add(environmentId);518 targets.add(environmentId);
475 }519 }
476 let wroteToTarget = false;520 let wroteToTarget = false;
521 let permissionDenied = false;
477 for (const targetId of targets) {522 for (const targetId of targets) {
478 const target = environments.find((candidate) => candidate.environmentId === targetId);523 const target = environments.find((candidate) => candidate.environmentId === targetId);
479 const targetPatch = filterSharedServerPatch(524 const targetPatch = filterSharedServerPatch(
...@@ -484,6 +529,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -484,6 +529,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
484 targetId === environmentId,529 targetId === environmentId,
485 );530 );
486 if (Object.keys(targetPatch).length === 0) continue;531 if (Object.keys(targetPatch).length === 0) continue;
532 const session = appAtomRegistry.get(environmentSession.sessionStateAtom(targetId));
533 if (
534 session._tag !== "Initial" &&
535 !requiredScopesForServerSettingsPatch(sharedPatch).every((scope) =>
536 readEnvironmentScope(targetId, scope),
537 )
538 ) {
539 permissionDenied = true;
540 continue;
541 }
487 wroteToTarget = true;542 wroteToTarget = true;
488 void persistServerSettings({543 void persistServerSettings({
489 environmentId: targetId,544 environmentId: targetId,
...@@ -492,7 +547,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -492,7 +547,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
492 }547 }
493 if (!wroteToTarget) {548 if (!wroteToTarget) {
494 warnUnsaved(549 warnUnsaved(
495 targets.size > 0 ? "Update older servers to save this setting." : undefined,550 permissionDenied
551 ? "This connection does not have permission to change these settings."
552 : targets.size > 0
553 ? "Update older servers to save this setting."
554 : undefined,
496 );555 );
497 }556 }
498 }557 }
...@@ -501,7 +560,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {...@@ -501,7 +560,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
501 void persistClientSettingsPatch(clientPatch);560 void persistClientSettingsPatch(clientPatch);
502 }561 }
503 },562 },
504 [environmentId, environments, persistServerSettings],563 [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds],
505 );564 );
506565
507 return updateSettings;566 return updateSettings;
apps/web/src/keybindings.test.ts+97
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 isTerminalSplitShortcut,25 isTerminalSplitShortcut,
26 isTerminalSplitVerticalShortcut,26 isTerminalSplitVerticalShortcut,
27 isTerminalToggleShortcut,27 isTerminalToggleShortcut,
28 resolveChatShortcutCommand,
28 resolveShortcutCommand,29 resolveShortcutCommand,
29 shouldShowThreadJumpHintsForModifiers,30 shouldShowThreadJumpHintsForModifiers,
30 shortcutLabelForCommand,31 shortcutLabelForCommand,
...@@ -886,6 +887,102 @@ describe("cross-command precedence", () => {...@@ -886,6 +887,102 @@ describe("cross-command precedence", () => {
886 });887 });
887});888});
888889
890describe("resolveChatShortcutCommand", () => {
891 it("runs the active environment's script shortcut instead of the primary environment's", () => {
892 const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
893 const active = compile([{ shortcut: modShortcut("t"), command: "script.setup.run" }]);
894
895 assert.strictEqual(
896 resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, {
897 platform: "Linux",
898 }),
899 "script.setup.run",
900 );
901 assert.isNull(
902 resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, active, {
903 platform: "Linux",
904 }),
905 );
906 });
907
908 it("resolves shared chords to the active environment's script ID", () => {
909 const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
910 const active = compile([{ shortcut: modShortcut("r"), command: "script.deploy.run" }]);
911
912 assert.strictEqual(
913 resolveChatShortcutCommand(event({ key: "r", metaKey: true }), primary, active, {
914 platform: "MacIntel",
915 }),
916 "script.deploy.run",
917 );
918 });
919
920 it("does not run primary scripts before the active environment's bindings arrive", () => {
921 const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
922
923 assert.isNull(
924 resolveChatShortcutCommand(event({ key: "r", ctrlKey: true }), primary, [], {
925 platform: "Linux",
926 }),
927 );
928 });
929
930 it("keeps primary app commands ahead of conflicting active script shortcuts", () => {
931 const primary = compile([{ shortcut: modShortcut("k"), command: "commandPalette.toggle" }]);
932 const active = compile([{ shortcut: modShortcut("k"), command: "script.setup.run" }]);
933
934 assert.strictEqual(
935 resolveChatShortcutCommand(event({ key: "k", ctrlKey: true }), primary, active, {
936 platform: "Linux",
937 }),
938 "commandPalette.toggle",
939 );
940 });
941
942 it("keeps app shortcuts on the primary environment when the active environment remaps them", () => {
943 const primary = compile([{ shortcut: modShortcut("j"), command: "terminal.toggle" }]);
944 const active = compile([{ shortcut: modShortcut("t"), command: "terminal.toggle" }]);
945
946 assert.strictEqual(
947 resolveChatShortcutCommand(event({ key: "j", ctrlKey: true }), primary, active, {
948 platform: "Linux",
949 }),
950 "terminal.toggle",
951 );
952 assert.isNull(
953 resolveChatShortcutCommand(event({ key: "t", ctrlKey: true }), primary, active, {
954 platform: "Linux",
955 }),
956 );
957 });
958
959 it.each([false, true])(
960 "preserves active binding precedence with terminalFocus=%s",
961 (terminalFocus) => {
962 const primary = compile([{ shortcut: modShortcut("r"), command: "script.setup.run" }]);
963 const active = compile([
964 { shortcut: modShortcut("r"), command: "script.deploy.run" },
965 {
966 shortcut: modShortcut("r"),
967 command: "terminal.new",
968 whenAst: whenIdentifier("terminalFocus"),
969 },
970 ]);
971 const options = { platform: "Linux", context: { terminalFocus } };
972 const shortcut = event({ key: "r", ctrlKey: true });
973
974 assert.strictEqual(
975 resolveChatShortcutCommand(shortcut, primary, active, options),
976 terminalFocus ? null : "script.deploy.run",
977 );
978 assert.strictEqual(
979 resolveChatShortcutCommand(shortcut, active, active, options),
980 resolveShortcutCommand(shortcut, active, options),
981 );
982 },
983 );
984});
985
889describe("resolveShortcutCommand", () => {986describe("resolveShortcutCommand", () => {
890 it("resolves a custom stop-thread shortcut", () => {987 it("resolves a custom stop-thread shortcut", () => {
891 const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]);988 const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]);
apps/web/src/keybindings.ts+19
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type ShortcutModifierStateLike,18 type ShortcutModifierStateLike,
19} from "@t3tools/shared/keybindings";19} from "@t3tools/shared/keybindings";
20import { isElectron } from "./env";20import { isElectron } from "./env";
21import { projectScriptIdFromCommand } from "./projectScripts";
2122
22export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";23export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
23export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";24export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
...@@ -169,6 +170,24 @@ export function resolveShortcutCommand(...@@ -169,6 +170,24 @@ export function resolveShortcutCommand(
169 return null;170 return null;
170}171}
171172
173/** App shortcuts use the primary environment; script shortcuts belong to the active project. */
174export function resolveChatShortcutCommand(
175 event: ShortcutEventLike,
176 primaryKeybindings: ResolvedKeybindingsConfig,
177 activeKeybindings: ResolvedKeybindingsConfig,
178 options?: ShortcutMatchOptions,
179): KeybindingCommand | null {
180 const primaryCommand = resolveShortcutCommand(event, primaryKeybindings, options);
181 if (primaryCommand !== null && projectScriptIdFromCommand(primaryCommand) === null) {
182 return primaryCommand;
183 }
184
185 const activeCommand = resolveShortcutCommand(event, activeKeybindings, options);
186 return activeCommand !== null && projectScriptIdFromCommand(activeCommand) !== null
187 ? activeCommand
188 : null;
189}
190
172export function formatShortcutKeyLabel(key: string): string {191export function formatShortcutKeyLabel(key: string): string {
173 if (key === " ") return "Space";192 if (key === " ") return "Space";
174 if (key.length === 1) return key.toUpperCase();193 if (key.length === 1) return key.toUpperCase();
apps/web/src/lib/attachmentUploadQueue.test.ts+111
...@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({...@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
18 executeAtomQuery: vi.fn(),18 executeAtomQuery: vi.fn(),
19 removeUpload: Symbol("remove-upload"),19 removeUpload: Symbol("remove-upload"),
20 runAtomCommand: vi.fn(),20 runAtomCommand: vi.fn(),
21 readEnvironmentScope: vi.fn(),
21 readPreparedConnection: vi.fn(),22 readPreparedConnection: vi.fn(),
22}));23}));
2324
...@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({...@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({
48}));49}));
4950
50vi.mock("../state/session", () => ({51vi.mock("../state/session", () => ({
52 readEnvironmentScope: mocks.readEnvironmentScope,
51 readPreparedConnection: mocks.readPreparedConnection,53 readPreparedConnection: mocks.readPreparedConnection,
52}));54}));
5355
...@@ -172,6 +174,7 @@ describe("attachmentUploadQueue", () => {...@@ -172,6 +174,7 @@ describe("attachmentUploadQueue", () => {
172 mocks.executeAtomQuery.mockReset();174 mocks.executeAtomQuery.mockReset();
173 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });175 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
174 mocks.runAtomCommand.mockReset();176 mocks.runAtomCommand.mockReset();
177 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
175 mocks.readPreparedConnection.mockReset();178 mocks.readPreparedConnection.mockReset();
176 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });179 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
177 mocks.runAtomCommand.mockImplementation(180 mocks.runAtomCommand.mockImplementation(
...@@ -266,6 +269,114 @@ describe("attachmentUploadQueue", () => {...@@ -266,6 +269,114 @@ describe("attachmentUploadQueue", () => {
266 expect(readAttachmentUpload(image.id)).toBeUndefined();269 expect(readAttachmentUpload(image.id)).toBeUndefined();
267 });270 });
268271
272 it("keeps an attachment local until its own environment grants write access", async () => {
273 const image = makeImage("permission-gain");
274 mocks.readEnvironmentScope.mockImplementation(
275 (environmentId) => environmentId === firstEnvironment,
276 );
277
278 startAttachmentUpload({ environmentId: secondEnvironment, image });
279 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
280 expect(TestXmlHttpRequest.requests).toHaveLength(0);
281 expect(readAttachmentUpload(image.id)).toBeUndefined();
282 expect(image.file).not.toBeNull();
283
284 mocks.readEnvironmentScope.mockReturnValue(true);
285 startAttachmentUpload({ environmentId: secondEnvironment, image });
286 await Promise.resolve();
287 const settled = awaitAttachmentUploads([image.id]);
288 TestXmlHttpRequest.requests[0]!.complete();
289 await settled;
290
291 expect(readAttachmentUpload(image.id)).toMatchObject({
292 status: "ready",
293 environmentId: secondEnvironment,
294 });
295 });
296
297 it("does not transfer bytes or retry when access is revoked while minting an upload", async () => {
298 const image = makeImage("revoke-before-bytes");
299 let finishMint: (() => void) | undefined;
300 mocks.runAtomCommand.mockImplementationOnce(
301 () =>
302 new Promise((resolve) => {
303 finishMint = () =>
304 resolve({
305 _tag: "Success",
306 value: {
307 attachmentId: "pending-revoked",
308 relativeUrl: "/api/attachments/upload/pending-revoked",
309 expiresAt: 1,
310 },
311 });
312 }),
313 );
314 startAttachmentUpload({ environmentId: firstEnvironment, image });
315 const settled = awaitAttachmentUploads([image.id]);
316 mocks.readEnvironmentScope.mockReturnValue(false);
317 finishMint!();
318 await settled;
319
320 expect(TestXmlHttpRequest.requests).toHaveLength(0);
321 expect(readAttachmentUpload(image.id)).toMatchObject({
322 status: "failed",
323 reason: "This connection cannot upload attachments.",
324 attachmentId: "pending-revoked",
325 });
326 retryAttachmentUpload({ environmentId: firstEnvironment, image });
327 expect(readAttachmentUpload(image.id)?.status).toBe("failed");
328 releaseAttachmentUpload(image.id);
329 expect(readAttachmentUpload(image.id)).toBeUndefined();
330 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(1);
331 });
332
333 it("does not mint queued uploads after access is revoked", async () => {
334 const images = Array.from({ length: 4 }, (_, index) => makeImage(`queued-${index}`));
335 for (const image of images) {
336 startAttachmentUpload({ environmentId: firstEnvironment, image });
337 }
338 await Promise.resolve();
339 expect(TestXmlHttpRequest.requests).toHaveLength(3);
340 const settled = awaitAttachmentUploads(images.map((image) => image.id));
341 mocks.readEnvironmentScope.mockReturnValue(false);
342 for (const request of TestXmlHttpRequest.requests) {
343 request.complete();
344 }
345 await settled;
346
347 expect(mocks.runAtomCommand).toHaveBeenCalledTimes(3);
348 expect(TestXmlHttpRequest.requests).toHaveLength(3);
349 expect(readAttachmentUpload(images[3]!.id)).toMatchObject({
350 status: "failed",
351 reason: "This connection cannot upload attachments.",
352 });
353 });
354
355 it("does not delete a persisted upload using another environment's grant", () => {
356 mocks.readEnvironmentScope.mockImplementation(
357 (environmentId) => environmentId === firstEnvironment,
358 );
359 releasePersistedAttachmentUpload({
360 id: "local-file",
361 environmentId: secondEnvironment,
362 attachmentId: "persisted-file",
363 });
364 expect(mocks.runAtomCommand).not.toHaveBeenCalled();
365
366 mocks.readEnvironmentScope.mockReturnValue(true);
367 releasePersistedAttachmentUpload({
368 id: "local-file",
369 environmentId: secondEnvironment,
370 attachmentId: "persisted-file",
371 });
372 expect(mocks.runAtomCommand).toHaveBeenCalledWith(
373 expect.anything(),
374 mocks.removeUpload,
375 { environmentId: secondEnvironment, input: { attachmentId: "persisted-file" } },
376 expect.anything(),
377 );
378 });
379
269 it("uploads images immediately and sends attachment references", async () => {380 it("uploads images immediately and sends attachment references", async () => {
270 const image = {381 const image = {
271 ...makeImage("image-1"),382 ...makeImage("image-1"),
apps/web/src/lib/attachmentUploadQueue.ts+33-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 AuthOrchestrationOperateScope,
2 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,3 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
3 type ChatAttachment,4 type ChatAttachment,
4 type EnvironmentId,5 type EnvironmentId,
...@@ -26,7 +27,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -26,7 +27,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
26import { environmentCatalog } from "../connection/catalog";27import { environmentCatalog } from "../connection/catalog";
27import { assetEnvironment } from "../state/assets";28import { assetEnvironment } from "../state/assets";
28import { attachmentEnvironment } from "../state/attachments";29import { attachmentEnvironment } from "../state/attachments";
29import { readPreparedConnection } from "../state/session";30import { readEnvironmentScope, readPreparedConnection } from "../state/session";
30import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";31import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
3132
32const MAX_UPLOADS_PER_ENVIRONMENT = 3;33const MAX_UPLOADS_PER_ENVIRONMENT = 3;
...@@ -138,6 +139,7 @@ function stampDraftFileUpload(job: UploadJob, attachmentId: string): void {...@@ -138,6 +139,7 @@ function stampDraftFileUpload(job: UploadJob, attachmentId: string): void {
138}139}
139140
140function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {141function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {
142 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
141 deletePendingAttachmentUpload({143 deletePendingAttachmentUpload({
142 registry: appAtomRegistry,144 registry: appAtomRegistry,
143 remove: attachmentEnvironment.remove,145 remove: attachmentEnvironment.remove,
...@@ -179,6 +181,15 @@ function uploadBytes(input: {...@@ -179,6 +181,15 @@ function uploadBytes(input: {
179}181}
180182
181async function runUpload(job: UploadJob): Promise<void> {183async function runUpload(job: UploadJob): Promise<void> {
184 if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) {
185 setUploadState(job.image.id, {
186 status: "failed",
187 environmentId: job.environmentId,
188 reason: "This connection cannot upload attachments.",
189 ...(job.previous ? { previous: job.previous } : {}),
190 });
191 return;
192 }
182 if (job.persistedAttachmentId) {193 if (job.persistedAttachmentId) {
183 const verification = await verifyPersistedAttachmentUpload({194 const verification = await verifyPersistedAttachmentUpload({
184 registry: appAtomRegistry,195 registry: appAtomRegistry,
...@@ -260,6 +271,15 @@ async function runUpload(job: UploadJob): Promise<void> {...@@ -260,6 +271,15 @@ async function runUpload(job: UploadJob): Promise<void> {
260 }271 }
261272
262 let lastStep = -1;273 let lastStep = -1;
274 if (!readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) {
275 setUploadState(job.image.id, {
276 status: "failed",
277 environmentId: job.environmentId,
278 reason: "This connection cannot upload attachments.",
279 ...(job.previous ? { previous: job.previous } : {}),
280 });
281 return;
282 }
263 const result = await runAttachmentUploadCycle({283 const result = await runAttachmentUploadCycle({
264 registry: appAtomRegistry,284 registry: appAtomRegistry,
265 createUploadUrl: attachmentEnvironment.createUploadUrl,285 createUploadUrl: attachmentEnvironment.createUploadUrl,
...@@ -272,6 +292,12 @@ async function runUpload(job: UploadJob): Promise<void> {...@@ -272,6 +292,12 @@ async function runUpload(job: UploadJob): Promise<void> {
272 sizeBytes: file.size,292 sizeBytes: file.size,
273 },293 },
274 resolveUploadUrl: (relativeUrl) => {294 resolveUploadUrl: (relativeUrl) => {
295 if (
296 job.cancelled ||
297 !readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)
298 ) {
299 return null;
300 }
275 const connection = readPreparedConnection(job.environmentId);301 const connection = readPreparedConnection(job.environmentId);
276 return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;302 return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;
277 },303 },
...@@ -295,7 +321,7 @@ async function runUpload(job: UploadJob): Promise<void> {...@@ -295,7 +321,7 @@ async function runUpload(job: UploadJob): Promise<void> {
295 },321 },
296 }),322 }),
297 onMinted: (attachmentId) => {323 onMinted: (attachmentId) => {
298 if (job.cancelled) {324 if (job.cancelled && readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) {
299 return "cancel";325 return "cancel";
300 }326 }
301 job.attachmentId = attachmentId;327 job.attachmentId = attachmentId;
...@@ -328,7 +354,9 @@ async function runUpload(job: UploadJob): Promise<void> {...@@ -328,7 +354,9 @@ async function runUpload(job: UploadJob): Promise<void> {
328 result.step === "mint"354 result.step === "mint"
329 ? "Upload could not start"355 ? "Upload could not start"
330 : result.step === "resolve-url"356 : result.step === "resolve-url"
331 ? "Not connected"357 ? readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)
358 ? "Not connected"
359 : "This connection cannot upload attachments."
332 : result.error instanceof Error360 : result.error instanceof Error
333 ? result.error.message361 ? result.error.message
334 : "Upload failed",362 : "Upload failed",
...@@ -388,6 +416,7 @@ export function startAttachmentUpload(input: {...@@ -388,6 +416,7 @@ export function startAttachmentUpload(input: {
388 /** Draft that owns the file; lets a background completion persist its ids. */416 /** Draft that owns the file; lets a background completion persist its ids. */
389 readonly draftTarget?: ComposerThreadTarget;417 readonly draftTarget?: ComposerThreadTarget;
390}): void {418}): void {
419 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return;
391 const existingJob = jobsByImageId.get(input.image.id);420 const existingJob = jobsByImageId.get(input.image.id);
392 if (existingJob?.environmentId === input.environmentId) {421 if (existingJob?.environmentId === input.environmentId) {
393 return;422 return;
...@@ -545,6 +574,7 @@ export function retryAttachmentUpload(input: {...@@ -545,6 +574,7 @@ export function retryAttachmentUpload(input: {
545 readonly image: ComposerImageAttachment | ComposerFileAttachment;574 readonly image: ComposerImageAttachment | ComposerFileAttachment;
546 readonly draftTarget?: ComposerThreadTarget;575 readonly draftTarget?: ComposerThreadTarget;
547}): void {576}): void {
577 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return;
548 const previous = readAttachmentUpload(input.image.id);578 const previous = readAttachmentUpload(input.image.id);
549 cancelAttachmentUpload(input.image.id);579 cancelAttachmentUpload(input.image.id);
550 // A failed state's `attachmentId` is always one this queue minted, so this580 // A failed state's `attachmentId` is always one this queue minted, so this
apps/web/src/lib/resourceTelemetryState.ts+5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
1import type {2import type {
2 EnvironmentId,3 EnvironmentId,
3 ResourceTelemetryHistoryInput,4 ResourceTelemetryHistoryInput,
...@@ -9,6 +10,7 @@ import { useCallback } from "react";...@@ -9,6 +10,7 @@ import { useCallback } from "react";
9import { usePrimaryEnvironment } from "../state/environments";10import { usePrimaryEnvironment } from "../state/environments";
10import { useEnvironmentQuery } from "../state/query";11import { useEnvironmentQuery } from "../state/query";
11import { serverEnvironment } from "../state/server";12import { serverEnvironment } from "../state/server";
13import { readEnvironmentScope } from "../state/session";
12import { useAtomCommand } from "../state/use-atom-command";14import { useAtomCommand } from "../state/use-atom-command";
1315
14export interface ResourceTelemetryState {16export interface ResourceTelemetryState {
...@@ -39,6 +41,9 @@ export function useResourceTelemetry(...@@ -39,6 +41,9 @@ export function useResourceTelemetry(
39 if (environmentId === null) {41 if (environmentId === null) {
40 throw new Error("No environment is selected.");42 throw new Error("No environment is selected.");
41 }43 }
44 if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) {
45 throw new Error("This connection cannot restart the resource monitor.");
46 }
42 const result = await retryCommand({ environmentId, input: {} });47 const result = await retryCommand({ environmentId, input: {} });
43 if (result._tag === "Failure") {48 if (result._tag === "Failure") {
44 throw Cause.squash(result.cause);49 throw Cause.squash(result.cause);
apps/web/src/routes/__root.tsx+9-43
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";1import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";2import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import {3import {
5 Outlet,4 Outlet,
6 Link,5 Link,
...@@ -20,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";...@@ -20,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";
20import { CommandPalette } from "../components/CommandPalette";19import { CommandPalette } from "../components/CommandPalette";
21import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";20import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
22import { ConfirmDialogHost } from "../components/ConfirmDialogHost";21import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
22import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning";
23import { FirstRunGate } from "../components/onboarding/FirstRunGate";23import { FirstRunGate } from "../components/onboarding/FirstRunGate";
24import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";24import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
25import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";25import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
...@@ -49,7 +49,6 @@ import {...@@ -49,7 +49,6 @@ import {
49 ToastProvider,49 ToastProvider,
50 toastManager,50 toastManager,
51} from "../components/ui/toast";51} from "../components/ui/toast";
52import { resolveAndPersistPreferredEditor } from "../editorPreferences";
53import { isElectron } from "../env";52import { isElectron } from "../env";
54import { cn } from "../lib/utils";53import { cn } from "../lib/utils";
55import { applyAppearanceFontVariables } from "~/appearanceFonts";54import { applyAppearanceFontVariables } from "~/appearanceFonts";
...@@ -66,9 +65,7 @@ import { configureClientTracing } from "../observability/clientTracing";...@@ -66,9 +65,7 @@ import { configureClientTracing } from "../observability/clientTracing";
66import { resolveInitialServerAuthGateState } from "../environments/primary";65import { resolveInitialServerAuthGateState } from "../environments/primary";
67import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";66import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
68import { isLocalEnvironmentDisabled } from "../localEnvironment";67import { isLocalEnvironmentDisabled } from "../localEnvironment";
69import { shellEnvironment } from "../state/shell";
70import { useAtomValue } from "@effect/atom-react";68import { useAtomValue } from "@effect/atom-react";
71import { useAtomCommand } from "../state/use-atom-command";
72import { useEnvironments, usePrimaryEnvironment } from "../state/environments";69import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
73import {70import {
74 primaryServerConfigAtom,71 primaryServerConfigAtom,
...@@ -496,9 +493,6 @@ function EventRouter({...@@ -496,9 +493,6 @@ function EventRouter({
496 const pathname = useLocation({ select: (loc) => loc.pathname });493 const pathname = useLocation({ select: (loc) => loc.pathname });
497 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);494 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
498 const primaryEnvironment = usePrimaryEnvironment();495 const primaryEnvironment = usePrimaryEnvironment();
499 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
500 reportFailure: false,
501 });
502 const serverConfig = useAtomValue(primaryServerConfigAtom);496 const serverConfig = useAtomValue(primaryServerConfigAtom);
503 const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom);497 const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom);
504 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);498 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
...@@ -575,42 +569,14 @@ function EventRouter({...@@ -575,42 +569,14 @@ function EventRouter({
575 stackedThreadToast({569 stackedThreadToast({
576 type: "warning",570 type: "warning",
577 title: "Invalid keybindings configuration",571 title: "Invalid keybindings configuration",
578 description: decision.message,572 description: (
579 actionVariant: "outline",573 <KeybindingsConfigWarning
580 actionProps: {574 message={decision.message}
581 children: "Open keybindings.json",575 environmentId={primaryEnvironment?.environmentId ?? null}
582 onClick: () => {576 configPath={serverConfig?.keybindingsConfigPath ?? null}
583 if (!serverConfig || !primaryEnvironment) {577 availableEditors={serverConfig?.availableEditors ?? []}
584 return;578 />
585 }579 ),
586
587 const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors);
588 if (!editor) {
589 return;
590 }
591 void (async () => {
592 const result = await openInEditor({
593 environmentId: primaryEnvironment.environmentId,
594 input: {
595 cwd: serverConfig.keybindingsConfigPath,
596 editor,
597 },
598 });
599 if (result._tag === "Success") {
600 return;
601 }
602 const error = squashAtomCommandFailure(result);
603 toastManager.add(
604 stackedThreadToast({
605 type: "error",
606 title: "Unable to open keybindings file",
607 description:
608 error instanceof Error ? error.message : "Unknown error opening file.",
609 }),
610 );
611 })();
612 },
613 },
614 }),580 }),
615 );581 );
616 });582 });
apps/web/src/state/session.ts+62-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";2import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
4import { useMemo } from "react";
4import * as Option from "effect/Option";5import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";6import { AsyncResult, Atom } from "effect/reactivity";
67
...@@ -9,6 +10,66 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -9,6 +10,66 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
910
10export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);11export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);
1112
13const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());
14
15/** Uses the selected environment's grant, including cached scopes during a refresh. */
16export function useEnvironmentScope(
17 environmentId: EnvironmentId | null,
18 scope: AuthEnvironmentScope,
19): boolean {
20 const result = useAtomValue(
21 environmentId === null
22 ? EMPTY_SESSION_STATE_ATOM
23 : environmentSession.sessionStateAtom(environmentId),
24 );
25 const session = Option.getOrNull(AsyncResult.value(result));
26 return (
27 result._tag !== "Failure" &&
28 session?.authenticated === true &&
29 session.scopes?.includes(scope) === true
30 );
31}
32
33/** Subscribe to the grants of every selected environment. */
34export function useEnvironmentsWithScope(
35 environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>,
36 scope: AuthEnvironmentScope,
37): ReadonlySet<EnvironmentId> {
38 const permitted = useMemo(
39 () =>
40 Atom.make((get) => {
41 const ids = new Set<EnvironmentId>();
42 for (const { environmentId } of environments) {
43 const result = get(environmentSession.sessionStateAtom(environmentId));
44 const session = Option.getOrNull(AsyncResult.value(result));
45 if (
46 result._tag !== "Failure" &&
47 session?.authenticated === true &&
48 session.scopes?.includes(scope)
49 ) {
50 ids.add(environmentId);
51 }
52 }
53 return ids;
54 }),
55 [environments, scope],
56 );
57 return useAtomValue(permitted);
58}
59
60export function readEnvironmentScope(
61 environmentId: EnvironmentId,
62 scope: AuthEnvironmentScope,
63): boolean {
64 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
65 const session = Option.getOrNull(AsyncResult.value(result));
66 return (
67 result._tag !== "Failure" &&
68 session?.authenticated === true &&
69 session.scopes?.includes(scope) === true
70 );
71}
72
12const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(73const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
13 Atom.withLabel("web-prepared-connection:empty"),74 Atom.withLabel("web-prepared-connection:empty"),
14);75);
apps/web/src/state/use-orchestration-command.test.ts created+85
...@@ -0,0 +1,85 @@
1import type { AtomCommand } from "@t3tools/client-runtime/state/runtime";
2import {
3 AuthOrchestrationOperateScope,
4 EnvironmentAuthorizationError,
5 EnvironmentId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { assert, beforeEach, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 allowed: new Set<string>(),
13 readScope: vi.fn(),
14 run: vi.fn(),
15}));
16
17vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
18vi.mock("./session", () => ({ readEnvironmentScope: state.readScope }));
19vi.mock("./use-atom-command", () => ({ useAtomCommand: () => state.run }));
20
21import { useOrchestrationCommand } from "./use-orchestration-command";
22
23const first = EnvironmentId.make("primary");
24const second = EnvironmentId.make("secondary");
25type Target = { environmentId: EnvironmentId; input: { threadId: string } };
26const command: AtomCommand<Target, string, Error> = { label: "thread mutation", run: state.run };
27const target = (environmentId: EnvironmentId): Target => ({
28 environmentId,
29 input: { threadId: "thread-1" },
30});
31
32beforeEach(() => {
33 state.allowed.clear();
34 state.run.mockReset().mockResolvedValue(AsyncResult.success("receipt"));
35 state.readScope
36 .mockReset()
37 .mockImplementation(
38 (environmentId, scope) =>
39 scope === AuthOrchestrationOperateScope && state.allowed.has(environmentId),
40 );
41});
42
43it("checks the command's environment instead of borrowing the primary grant", async () => {
44 state.allowed.add(first);
45 const mutate = useOrchestrationCommand(command, { reportFailure: false });
46 const result = await mutate(target(second));
47 assert(result._tag === "Failure");
48 const error = Cause.squash(result.cause);
49 expect(error).toBeInstanceOf(EnvironmentAuthorizationError);
50 expect(error).toMatchObject({ requiredScope: AuthOrchestrationOperateScope });
51 expect(state.run).not.toHaveBeenCalled();
52
53 expect(await mutate(target(first))).toMatchObject({ _tag: "Success", value: "receipt" });
54 expect(state.run).toHaveBeenCalledWith(target(first));
55});
56
57it("a retained callback follows both revocation and a new grant without remounting", async () => {
58 state.allowed.add(second);
59 const mutate = useOrchestrationCommand(command);
60 state.allowed.delete(second);
61 expect((await mutate(target(second)))._tag).toBe("Failure");
62 expect(state.run).not.toHaveBeenCalled();
63
64 state.allowed.add(second);
65 expect((await mutate(target(second)))._tag).toBe("Success");
66 expect(state.run).toHaveBeenCalledTimes(1);
67});
68
69it("does not dispatch a later mutation after access changes while awaiting a receipt", async () => {
70 state.allowed.add(second);
71 let finish: (() => void) | undefined;
72 state.run.mockImplementationOnce(
73 () =>
74 new Promise((resolve) => {
75 finish = () => resolve(AsyncResult.success("first receipt"));
76 }),
77 );
78 const mutate = useOrchestrationCommand(command);
79 const pending = mutate(target(second));
80 state.allowed.delete(second);
81 finish!();
82 expect((await pending)._tag).toBe("Success");
83 expect((await mutate(target(second)))._tag).toBe("Failure");
84 expect(state.run).toHaveBeenCalledTimes(1);
85});
apps/web/src/state/use-orchestration-command.ts created+43
...@@ -0,0 +1,43 @@
1import type {
2 AtomCommand,
3 AtomCommandOptions,
4 AtomCommandResult,
5} from "@t3tools/client-runtime/state/runtime";
6import {
7 AuthOrchestrationOperateScope,
8 EnvironmentAuthorizationError,
9 type EnvironmentId,
10} from "@t3tools/contracts";
11import * as Cause from "effect/Cause";
12import { AsyncResult } from "effect/reactivity";
13import { useCallback } from "react";
14
15import { readEnvironmentScope } from "./session";
16import { useAtomCommand } from "./use-atom-command";
17
18export const ORCHESTRATION_OPERATE_DENIED_MESSAGE =
19 "This connection cannot change threads or projects.";
20
21/** Recheck the target grant for every step of a thread or project mutation. */
22export function useOrchestrationCommand<W extends { readonly environmentId: EnvironmentId }, A, E>(
23 command: AtomCommand<W, A, E>,
24 options?: string | AtomCommandOptions,
25): (value: W) => Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> {
26 const run = useAtomCommand(command, options);
27 return useCallback(
28 async (value: W): Promise<AtomCommandResult<A, E | EnvironmentAuthorizationError>> => {
29 if (!readEnvironmentScope(value.environmentId, AuthOrchestrationOperateScope)) {
30 return AsyncResult.failure(
31 Cause.fail(
32 new EnvironmentAuthorizationError({
33 requiredScope: AuthOrchestrationOperateScope,
34 message: ORCHESTRATION_OPERATE_DENIED_MESSAGE,
35 }),
36 ),
37 );
38 }
39 return run(value);
40 },
41 [run],
42 );
43}
docs/internals/environment-auth.md+5
...@@ -58,6 +58,11 @@ extra authority: [every RPC declares a required...@@ -58,6 +58,11 @@ extra authority: [every RPC declares a required
58scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC58scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
59group's `RpcScopeAuthorization` middleware checks it before any handler runs.59group's `RpcScopeAuthorization` middleware checks it before any handler runs.
6060
61Self-update must work across authorization protocol changes. New servers advertise
62`auth.serverUpdateScope`; only an older server that omits it uses
63`orchestration:operate` for updates. An unchanged grant on an upgraded server must
64still include `environment:maintain`.
65
61Desktop restarts forget the previous local bearer token, so its reusable66Desktop restarts forget the previous local bearer token, so its reusable
62bootstrap grant replaces earlier sessions for the same subject and method.67bootstrap grant replaces earlier sessions for the same subject and method.
63Revocation and insertion share a [database68Revocation and insertion share a [database
docs/user/remote-access.md+10
...@@ -277,6 +277,16 @@ in web or desktop, use **Add Environment** with the fresh link or code; pairing...@@ -277,6 +277,16 @@ in web or desktop, use **Add Environment** with the fresh link or code; pairing
277the same environment replaces its saved grant. Reconnecting alone does not change277the same environment replaces its saved grant. Reconnecting alone does not change
278permissions.278permissions.
279279
280Grouping checkouts does not combine their permissions. Shared project settings
281require `orchestration:operate` on every member environment; actions on one
282checkout use that checkout's permissions.
283
284Settings changes, provider management, and environment maintenance can be granted
285separately from access administration. New standard pairings include these
286permissions. Existing clients keep their original grants after an update; to
287receive newly separated permissions, pair the client again with the scopes it
288needs. Reconnecting or refreshing a session does not expand its grant.
289
280To remove an environment from T3 Connect, open your account menu's **T3 Connect**290To remove an environment from T3 Connect, open your account menu's **T3 Connect**
281page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This291page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
282revokes its cloud access and frees its host space even when the environment is292revokes its cloud access and frees its host space even when the environment is
oxlint-plugin-t3code/index.ts+2
...@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e...@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";10import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
11import noTestInLoop from "./rules/no-test-in-loop.ts";11import noTestInLoop from "./rules/no-test-in-loop.ts";
12import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
12import noUnscopedHas from "./rules/no-unscoped-has.ts";13import noUnscopedHas from "./rules/no-unscoped-has.ts";
13import preferCatchTags from "./rules/prefer-catch-tags.ts";14import preferCatchTags from "./rules/prefer-catch-tags.ts";
14import requireSuppressionReason from "./rules/require-suppression-reason.ts";15import requireSuppressionReason from "./rules/require-suppression-reason.ts";
...@@ -27,6 +28,7 @@ export default definePlugin({...@@ -27,6 +28,7 @@ export default definePlugin({
27 "no-native-title-tooltip": noNativeTitleTooltip,28 "no-native-title-tooltip": noNativeTitleTooltip,
28 "no-raw-mcp-registration": noRawMcpRegistration,29 "no-raw-mcp-registration": noRawMcpRegistration,
29 "no-test-in-loop": noTestInLoop,30 "no-test-in-loop": noTestInLoop,
31 "no-rpc-permission-bypass": noRpcPermissionBypass,
30 "no-unscoped-has": noUnscopedHas,32 "no-unscoped-has": noUnscopedHas,
31 "prefer-catch-tags": preferCatchTags,33 "prefer-catch-tags": preferCatchTags,
32 "require-suppression-reason": requireSuppressionReason,34 "require-suppression-reason": requireSuppressionReason,
oxlint-plugin-t3code/rules/no-rpc-permission-bypass.test.ts created+69
...@@ -0,0 +1,69 @@
1import { createOxlintRuleHarness } from "../test/utils.ts";
2const state = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", {
3 filename: "fixture.ts",
4});
5state.invalid("blocks direct session calls", "session.client[tag](input);");
6state.invalid("blocks extracting the raw client", 'const raw = session["client"];');
7state.invalid(
8 "blocks replacing the permission guard",
9 'import { RpcPermissionGuard as Guard } from "../rpc/client.ts";',
10);
11state.invalid(
12 "blocks namespace guard access",
13 'import * as Rpc from "../rpc/client.ts"; const guard = Rpc.RpcPermissionGuard;',
14);
15state.invalid("blocks raw protocol imports", 'import { makeClient } from "../rpc/protocol.ts";');
16state.valid(
17 "allows typed requests",
18 'import { request } from "../rpc/client.ts"; request(method, input);',
19);
20const rpc = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", {
21 filename: "fixture.ts",
22 ruleOptions: [{ allowRawClientAccess: true }],
23});
24rpc.valid("allows the transport boundary", "session.client[tag](input);");
25
26const app = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", {
27 filename: "fixture.ts",
28});
29app.invalid(
30 "blocks guard imports through the barrel",
31 'import { RpcPermissionGuard as Guard } from "@t3tools/client-runtime/rpc";',
32);
33app.invalid(
34 "blocks explicit index imports",
35 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc/index.ts";',
36);
37app.invalid(
38 "blocks raw session access in subscription callbacks",
39 "subscribeDynamic(tag, session => session.client[method](input));",
40);
41app.invalid(
42 "blocks raw client destructuring",
43 "const { client: raw } = session; raw[method](input);",
44);
45app.valid(
46 "allows public typed RPC helpers",
47 'import { request, runStream } from "@t3tools/client-runtime/rpc";',
48);
49
50rpc.invalid(
51 "raw client allowance does not permit installing the guard",
52 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc";',
53);
54const boundary = createOxlintRuleHarness("t3code/no-rpc-permission-bypass", {
55 filename: "fixture.ts",
56 ruleOptions: [{ allowGuardInstallation: true }],
57});
58boundary.valid(
59 "permits guard installation when configured",
60 'import { RpcPermissionGuard } from "@t3tools/client-runtime/rpc"; RpcPermissionGuard.of({ authorize });',
61);
62boundary.invalid(
63 "guard installation allowance does not permit raw clients",
64 "session.client[tag](input);",
65);
66boundary.invalid(
67 "guard installation allowance does not permit raw protocol imports",
68 'import { makeWsRpcProtocolClient } from "../rpc/protocol.ts";',
69);
oxlint-plugin-t3code/rules/no-rpc-permission-bypass.ts created+91
...@@ -0,0 +1,91 @@
1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3import { getPropertyName } from "../utils.ts";
4
5const readOptions = (options: ReadonlyArray<unknown>) => {
6 const [first] = options;
7 return {
8 allowRawClientAccess:
9 typeof first === "object" &&
10 first !== null &&
11 "allowRawClientAccess" in first &&
12 first.allowRawClientAccess === true,
13 allowGuardInstallation:
14 typeof first === "object" &&
15 first !== null &&
16 "allowGuardInstallation" in first &&
17 first.allowGuardInstallation === true,
18 };
19};
20
21/** A guardrail for ordinary edits, not a security boundary against deliberate casts or aliases. */
22export default defineRule({
23 meta: {
24 type: "problem",
25 docs: {
26 description:
27 "Keep raw RPC access and permission guard installation inside their shared boundaries.",
28 },
29 schema: [
30 {
31 type: "object",
32 properties: {
33 allowRawClientAccess: {
34 type: "boolean",
35 description: "Permit .client access for non-RPC clients in the configured files.",
36 },
37 allowGuardInstallation: {
38 type: "boolean",
39 description: "Permit installing RpcPermissionGuard in a reviewed command boundary.",
40 },
41 },
42 additionalProperties: false,
43 },
44 ],
45 defaultOptions: [{ allowRawClientAccess: false, allowGuardInstallation: false }],
46 },
47 create(context) {
48 const { allowRawClientAccess, allowGuardInstallation } = readOptions(context.options);
49 const report = (node: NonNullable<Parameters<typeof context.report>[0]["node"]>) =>
50 context.report({
51 node,
52 message:
53 "Use permission-aware environment commands. Raw RPC access and RpcPermissionGuard belong in the shared RPC/command boundary.",
54 });
55 return {
56 ImportDeclaration(node) {
57 if (typeof node.source.value !== "string") return;
58 const source = node.source.value;
59 if (/(?:^|\/)rpc\/protocol(?:\.ts)?$/.test(source) && node.importKind !== "type")
60 report(node);
61 if (
62 !allowGuardInstallation &&
63 /(?:^|\/)rpc(?:\/(?:client|index)(?:\.ts)?)?$/.test(source)
64 ) {
65 for (const specifier of node.specifiers) {
66 if (
67 specifier.type === "ImportSpecifier" &&
68 Option.getOrNull(getPropertyName(specifier.imported)) === "RpcPermissionGuard"
69 )
70 report(specifier);
71 }
72 }
73 },
74 VariableDeclarator(node) {
75 if (allowRawClientAccess || node.id.type !== "ObjectPattern") return;
76 for (const property of node.id.properties) {
77 if (
78 property.type === "Property" &&
79 Option.getOrNull(getPropertyName(property.key)) === "client"
80 )
81 report(property);
82 }
83 },
84 MemberExpression(node) {
85 const property = Option.getOrNull(getPropertyName(node.property));
86 if (!allowGuardInstallation && property === "RpcPermissionGuard") report(node);
87 if (!allowRawClientAccess && property === "client") report(node);
88 },
89 };
90 },
91});
packages/client-runtime/src/rpc/client.ts+57-4
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import {1import {
2 EnvironmentAuthorizationError,2 EnvironmentAuthorizationError,
3 clientRpcRequiredScopes,
4 type EnvironmentId,
5 type ClientGuardedRpcTag,
3 ORCHESTRATION_V2_WS_METHODS,6 ORCHESTRATION_V2_WS_METHODS,
4 WS_METHODS,7 WS_METHODS,
5} from "@t3tools/contracts";8} from "@t3tools/contracts";
...@@ -146,7 +149,40 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer...@@ -146,7 +149,40 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer
146 },149 },
147);150);
148151
149export const request = Effect.fn("EnvironmentRpc.request")(function* <152/** Installed by the shared command boundary. A missing boundary denies protected writes. */
153export class RpcPermissionGuard extends Context.Reference<{
154 readonly authorize: (
155 environmentId: EnvironmentId,
156 method: string,
157 input: unknown,
158 ) => Effect.Effect<void, EnvironmentAuthorizationError>;
159}>("@t3tools/client-runtime/rpc/RpcPermissionGuard", {
160 defaultValue: () => ({
161 authorize: (_environmentId, method, input) => {
162 const scope = clientRpcRequiredScopes(method, input)[0];
163 return scope === undefined
164 ? Effect.void
165 : Effect.fail(
166 new EnvironmentAuthorizationError({
167 requiredScope: scope,
168 message: `This connection requires ${scope}.`,
169 }),
170 );
171 },
172 }),
173}) {}
174
175const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* (
176 method: string,
177 input: unknown,
178) {
179 if (clientRpcRequiredScopes(method, input).length === 0) return;
180 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
181 const guard = yield* RpcPermissionGuard;
182 yield* guard.authorize(supervisor.target.environmentId, method, input);
183});
184
185export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* <
150 TTag extends EnvironmentUnaryRpcTag,186 TTag extends EnvironmentUnaryRpcTag,
151>(tag: TTag, input: EnvironmentRpcInput<TTag>) {187>(tag: TTag, input: EnvironmentRpcInput<TTag>) {
152 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;188 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
...@@ -155,6 +191,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <...@@ -155,6 +191,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <
155 "rpc.method": tag,191 "rpc.method": tag,
156 });192 });
157 const session = yield* currentSession();193 const session = yield* currentSession();
194 yield* authorizeRequest(tag, input);
158 const observer = yield* EnvironmentRpcRequestObserver;195 const observer = yield* EnvironmentRpcRequestObserver;
159 const method = session.client[tag] as (196 const method = session.client[tag] as (
160 input: EnvironmentRpcInput<TTag>,197 input: EnvironmentRpcInput<TTag>,
...@@ -166,16 +203,19 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <...@@ -166,16 +203,19 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <
166 return yield* method(input).pipe(Effect.ensuring(completeObservation));203 return yield* method(input).pipe(Effect.ensuring(completeObservation));
167});204});
168205
169export function runStream<TTag extends EnvironmentStreamCommandRpcTag>(206function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(
170 tag: TTag,207 tag: TTag,
171 input: EnvironmentRpcInput<TTag>,208 input: EnvironmentRpcInput<TTag>,
172): Stream.Stream<209): Stream.Stream<
173 EnvironmentRpcStreamValue<TTag>,210 EnvironmentRpcStreamValue<TTag>,
174 EnvironmentRpcStreamFailure<TTag> | EnvironmentRpcUnavailableError,211 | EnvironmentRpcStreamFailure<TTag>
212 | EnvironmentRpcUnavailableError
213 | EnvironmentAuthorizationError,
175 EnvironmentSupervisor.EnvironmentSupervisor214 EnvironmentSupervisor.EnvironmentSupervisor
176> {215> {
177 return Stream.unwrap(216 return Stream.unwrap(
178 currentSession().pipe(217 authorizeRequest(tag, input).pipe(
218 Effect.andThen(currentSession()),
179 Effect.map((session) => {219 Effect.map((session) => {
180 const method = session.client[tag] as (220 const method = session.client[tag] as (
181 input: EnvironmentRpcInput<TTag>,221 input: EnvironmentRpcInput<TTag>,
...@@ -393,3 +433,16 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(...@@ -393,3 +433,16 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
393> {433> {
394 return subscribeDynamic(tag, () => Effect.succeed(input), options);434 return subscribeDynamic(tag, () => Effect.succeed(input), options);
395}435}
436
437/** Protected writes must go through the permission-aware command layer. */
438export const request = <TTag extends Exclude<EnvironmentUnaryRpcTag, ClientGuardedRpcTag>>(
439 tag: TTag,
440 input: EnvironmentRpcInput<TTag>,
441) => requestGuarded(tag, input);
442
443export const runStream = <
444 TTag extends Exclude<EnvironmentStreamCommandRpcTag, ClientGuardedRpcTag>,
445>(
446 tag: TTag,
447 input: EnvironmentRpcInput<TTag>,
448) => runStreamGuarded(tag, input);
packages/client-runtime/src/rpc/index.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1export * from "./client.ts";1export * from "./client.ts";
2export * from "./http.ts";2export * from "./http.ts";
3export * from "./protocol.ts";3export type { WsRpcProtocolClient } from "./protocol.ts";
4export { type RpcSession } from "./session.ts";4export { type RpcSession } from "./session.ts";
packages/client-runtime/src/state/commandPermissions.test.ts created+152
...@@ -0,0 +1,152 @@
1import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vite-plus/test";
3import {
4 AuthOrchestrationOperateScope,
5 EnvironmentId,
6 ScheduledTaskId,
7 WS_METHODS,
8 type AuthSessionState,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as TestClock from "effect/testing/TestClock";
15import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
16import { EnvironmentRegistry } from "../connection/registry.ts";
17import { createCommandPermissions } from "./commandPermissions.ts";
18import { createEnvironmentRpcCommand } from "./runtime.ts";
19
20vi.mock("./session.ts", () => ({
21 createEnvironmentSessionAtoms: () => ({ sessionStateAtom: sessions }),
22}));
23const sessions = Atom.family((_id: EnvironmentId) =>
24 Atom.make<AsyncResult.AsyncResult<AuthSessionState, string>>(AsyncResult.initial()),
25);
26const env = EnvironmentId.make("target");
27const other = EnvironmentId.make("other");
28const grant = (allowed: boolean): AuthSessionState => ({
29 authenticated: true,
30 auth: {
31 policy: "remote-reachable",
32 bootstrapMethods: [],
33 sessionMethods: [],
34 sessionCookieName: "test",
35 },
36 scopes: allowed ? [AuthOrchestrationOperateScope] : [],
37});
38const runtime = Atom.runtime(
39 Layer.succeed(EnvironmentRegistry, {
40 run: (_id: EnvironmentId, effect: Effect.Effect<unknown>) => effect,
41 } as unknown as EnvironmentRegistry["Service"]),
42);
43const permissions = createCommandPermissions(runtime, WS_METHODS.scheduledTasksDelete);
44const setup = Effect.gen(function* () {
45 const registry = AtomRegistry.make();
46 registry.mount(sessions(env));
47 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
48 return registry;
49});
50
51describe("command permissions", () => {
52 it.effect("uses the target grant for both availability and execution", () =>
53 Effect.scoped(
54 Effect.gen(function* () {
55 const registry = yield* setup;
56 registry.set(sessions(env), AsyncResult.success(grant(true)));
57 expect(registry.get(permissions.permissionAtom(env))).toBe(true);
58 expect(permissions.permissionAtom(env)).toBe(permissions.permissionAtom(env));
59 yield* permissions.authorize(registry, env);
60 registry.set(sessions(other), AsyncResult.success(grant(false)));
61 expect(registry.get(permissions.permissionAtom(other))).toBe(false);
62 const denied = yield* permissions.authorize(registry, other).pipe(Effect.flip);
63 expect(denied._tag).toBe("EnvironmentAuthorizationError");
64 }),
65 ),
66 );
67 it.effect("waits for an initial grant", () =>
68 Effect.scoped(
69 Effect.gen(function* () {
70 const registry = yield* setup;
71 const fiber = yield* permissions.authorize(registry, env).pipe(Effect.forkChild);
72 yield* Effect.yieldNow;
73 registry.set(sessions(env), AsyncResult.success(grant(true)));
74 yield* Fiber.join(fiber);
75 }),
76 ),
77 );
78 it.effect("bounds a session that never loads", () =>
79 Effect.scoped(
80 Effect.gen(function* () {
81 const registry = yield* setup;
82 const fiber = yield* permissions
83 .authorize(registry, env)
84 .pipe(Effect.flip, Effect.forkChild);
85 yield* TestClock.adjust("6 seconds");
86 expect((yield* Fiber.join(fiber))._tag).toBe("EnvironmentAuthorizationError");
87 }),
88 ),
89 );
90 it.effect(
91 "denies failed and unauthenticated sessions, but accepts a cached refreshing grant",
92 () =>
93 Effect.scoped(
94 Effect.gen(function* () {
95 const registry = yield* setup;
96 registry.set(sessions(env), AsyncResult.failure(Cause.fail("offline")));
97 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
98 "EnvironmentAuthorizationError",
99 );
100 registry.set(
101 sessions(env),
102 AsyncResult.success({ ...grant(true), authenticated: false }),
103 );
104 expect((yield* permissions.authorize(registry, env).pipe(Effect.flip))._tag).toBe(
105 "EnvironmentAuthorizationError",
106 );
107 registry.set(sessions(env), AsyncResult.waiting(AsyncResult.success(grant(true))));
108 yield* permissions.authorize(registry, env);
109 }),
110 ),
111 );
112 it("rechecks permission after waiting in a serial command lane", async () => {
113 const registry = AtomRegistry.make();
114 const unmount = registry.mount(sessions(env));
115 registry.set(sessions(env), AsyncResult.success(grant(true)));
116 let release!: () => void;
117 let started!: () => void;
118 const entered = new Promise<void>((resolve) => {
119 started = resolve;
120 });
121 const gate = new Promise<void>((resolve) => {
122 release = resolve;
123 });
124 let calls = 0;
125 const command = createEnvironmentRpcCommand(runtime, {
126 label: "test.delete",
127 tag: WS_METHODS.scheduledTasksDelete,
128 concurrency: { mode: "serial", key: () => "target" },
129 execute: () =>
130 Effect.promise(async () => {
131 calls++;
132 started();
133 await gate;
134 return { id: ScheduledTaskId.make("task") };
135 }),
136 });
137 const target = { environmentId: env, input: { id: ScheduledTaskId.make("task") } };
138 try {
139 const first = command.run(registry, target);
140 await entered;
141 const second = command.run(registry, target);
142 registry.set(sessions(env), AsyncResult.success(grant(false)));
143 release();
144 expect((await first)._tag).toBe("Success");
145 expect((await second)._tag).toBe("Failure");
146 expect(calls).toBe(1);
147 } finally {
148 unmount();
149 registry.dispose();
150 }
151 });
152});
packages/client-runtime/src/state/commandPermissions.ts created+97
...@@ -0,0 +1,97 @@
1import {
2 clientRpcRequiredScopes,
3 EnvironmentAuthorizationError,
4 type EnvironmentId,
5 type AuthSessionState,
6 type AuthEnvironmentScope,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
10import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
11import type { EnvironmentRegistry } from "../connection/registry.ts";
12import { createEnvironmentSessionAtoms } from "./session.ts";
13
14function grants(session: AuthSessionState, scope: AuthEnvironmentScope) {
15 return session.authenticated && session.scopes?.includes(scope) === true;
16}
17
18/** UI availability and dispatch use the same target session and method policy. */
19function makeCommandPermissions<R, E>(
20 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
21 method: string,
22) {
23 const sessions = createEnvironmentSessionAtoms(runtime);
24 const requiredScopes = (input?: unknown) => clientRpcRequiredScopes(method, input);
25 const atomsByEnvironment = Atom.family((environmentId: EnvironmentId | null) =>
26 Atom.family((scopeKey: string) =>
27 Atom.make((get) => {
28 if (environmentId === null) return false;
29 const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[];
30 if (scopes.length === 0) return true;
31 const result = get(sessions.sessionStateAtom(environmentId));
32 const session = Option.getOrNull(AsyncResult.value(result));
33 return (
34 result._tag !== "Failure" &&
35 session !== null &&
36 scopes.every((scope) => grants(session, scope))
37 );
38 }),
39 ),
40 );
41 const permissionAtom = (environmentId: EnvironmentId | null, input?: unknown) =>
42 atomsByEnvironment(environmentId)(requiredScopes(input).join(","));
43 const authorize = (
44 registry: AtomRegistry.AtomRegistry,
45 environmentId: EnvironmentId,
46 input?: unknown,
47 ) =>
48 Effect.suspend(() => {
49 const scopes = requiredScopes(input);
50 if (scopes.length === 0) return Effect.void;
51 const atom = sessions.sessionStateAtom(environmentId);
52 return Effect.scoped(
53 Effect.gen(function* () {
54 yield* AtomRegistry.mount(registry, atom);
55 const session = yield* AtomRegistry.getResult(registry, atom, {
56 suspendOnWaiting: false,
57 }).pipe(
58 Effect.timeoutOption(6_000),
59 Effect.catch(() => Effect.succeed(Option.none<AuthSessionState>())),
60 );
61 const missing = scopes.find(
62 (scope) => Option.isNone(session) || !grants(session.value, scope),
63 );
64 if (missing !== undefined)
65 return yield* Effect.fail(
66 new EnvironmentAuthorizationError({
67 requiredScope: missing,
68 message: `This connection requires ${missing}.`,
69 }),
70 );
71 }),
72 );
73 });
74 return { requiredScopes, permissionAtom, authorize };
75}
76
77const permissionsByRuntime = new WeakMap<
78 object,
79 Map<string, ReturnType<typeof makeCommandPermissions>>
80>();
81
82/** Reuse availability atoms and dispatch guards across commands on the same runtime. */
83export function createCommandPermissions<R, E>(
84 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
85 method: string,
86) {
87 let permissions = permissionsByRuntime.get(runtime);
88 if (permissions === undefined) {
89 permissions = new Map();
90 permissionsByRuntime.set(runtime, permissions);
91 }
92 const existing = permissions.get(method);
93 if (existing !== undefined) return existing;
94 const created = makeCommandPermissions(runtime, method);
95 permissions.set(method, created);
96 return created;
97}
packages/client-runtime/src/state/environmentStreams.ts created+35
...@@ -0,0 +1,35 @@
1import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import * as EnvironmentRegistry from "../connection/registry.ts";
5import type * as EnvironmentSupervisor from "../connection/supervisor.ts";
6
7export function runStreamInEnvironment<A, E, R>(
8 environmentId: EnvironmentIdType,
9 stream: Stream.Stream<A, E, R>,
10): Stream.Stream<
11 A,
12 E | EnvironmentRegistry.EnvironmentNotRegisteredError,
13 EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
14> {
15 return Stream.unwrap(
16 EnvironmentRegistry.EnvironmentRegistry.pipe(
17 Effect.map((registry) => registry.runStream(environmentId, stream)),
18 ),
19 );
20}
21
22export function followStreamInEnvironment<A, E, R>(
23 environmentId: EnvironmentIdType,
24 stream: Stream.Stream<A, E, R>,
25): Stream.Stream<
26 A,
27 E,
28 EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
29> {
30 return Stream.unwrap(
31 EnvironmentRegistry.EnvironmentRegistry.pipe(
32 Effect.map((registry) => registry.followStream(environmentId, stream)),
33 ),
34 );
35}
packages/client-runtime/src/state/pullRequestRouting.ts+1-1
...@@ -24,7 +24,7 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts";...@@ -24,7 +24,7 @@ import type { ConnectionCatalogEntry } from "../connection/catalog.ts";
24import * as ConnectionProfileStore from "../connection/profileStore.ts";24import * as ConnectionProfileStore from "../connection/profileStore.ts";
25import type { RpcSession } from "../rpc/session.ts";25import type { RpcSession } from "../rpc/session.ts";
26import {26import {
27 request,27 requestGuarded as request,
28 EnvironmentRpcUnavailableError,28 EnvironmentRpcUnavailableError,
29 type EnvironmentRpcInput,29 type EnvironmentRpcInput,
30 type EnvironmentUnaryRpcTag,30 type EnvironmentUnaryRpcTag,
packages/client-runtime/src/state/runtime.ts+30-34
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts";1import { createCommandPermissions } from "./commandPermissions.ts";
2import { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
3export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
4import {
5 type ClientGuardedRpcTag,
6 EnvironmentId,
7 type EnvironmentId as EnvironmentIdType,
8} from "@t3tools/contracts";
2import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
...@@ -19,6 +26,8 @@ import {...@@ -19,6 +26,8 @@ import {
19 type EnvironmentUnaryRpcTag,26 type EnvironmentUnaryRpcTag,
20 EnvironmentRpcUnavailableError,27 EnvironmentRpcUnavailableError,
21 request,28 request,
29 requestGuarded,
30 RpcPermissionGuard,
22 subscribe,31 subscribe,
23} from "../rpc/client.ts";32} from "../rpc/client.ts";
24import * as EnvironmentSupervisor from "../connection/supervisor.ts";33import * as EnvironmentSupervisor from "../connection/supervisor.ts";
...@@ -459,36 +468,6 @@ function runInEnvironment<A, E, R>(...@@ -459,36 +468,6 @@ function runInEnvironment<A, E, R>(
459 );468 );
460}469}
461470
462export function runStreamInEnvironment<A, E, R>(
463 environmentId: EnvironmentIdType,
464 stream: Stream.Stream<A, E, R>,
465): Stream.Stream<
466 A,
467 E | EnvironmentRegistry.EnvironmentNotRegisteredError,
468 EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
469> {
470 return Stream.unwrap(
471 EnvironmentRegistry.EnvironmentRegistry.pipe(
472 Effect.map((registry) => registry.runStream(environmentId, stream)),
473 ),
474 );
475}
476
477export function followStreamInEnvironment<A, E, R>(
478 environmentId: EnvironmentIdType,
479 stream: Stream.Stream<A, E, R>,
480): Stream.Stream<
481 A,
482 E,
483 EnvironmentRegistry.EnvironmentRegistry | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
484> {
485 return Stream.unwrap(
486 EnvironmentRegistry.EnvironmentRegistry.pipe(
487 Effect.map((registry) => registry.followStream(environmentId, stream)),
488 ),
489 );
490}
491
492/**471/**
493 * Refreshes a query when `signal` changes, but only while something reads it.472 * Refreshes a query when `signal` changes, but only while something reads it.
494 * `Atom.makeRefreshOnSignal` subscribes to the signal, so a query that outlives its view on an473 * `Atom.makeRefreshOnSignal` subscribes to the signal, so a query that outlives its view on an
...@@ -660,7 +639,11 @@ export function createEnvironmentCommand<R, ER, Input, A, E>(...@@ -660,7 +639,11 @@ export function createEnvironmentCommand<R, ER, Input, A, E>(
660 });639 });
661}640}
662641
663export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends EnvironmentUnaryRpcTag>(642export function createEnvironmentRpcQueryAtomFamily<
643 R,
644 ER,
645 TTag extends Exclude<EnvironmentUnaryRpcTag, ClientGuardedRpcTag>,
646>(
664 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,647 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,
665 options: {648 options: {
666 readonly label: string;649 readonly label: string;
...@@ -767,7 +750,8 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -767,7 +750,8 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
767 ) => Effect.Effect<void, never, R>;750 ) => Effect.Effect<void, never, R>;
768 },751 },
769) {752) {
770 return createEnvironmentCommand(runtime, {753 const permissions = createCommandPermissions(runtime, options.tag);
754 const command = createEnvironmentCommand(runtime, {
771 label: options.label,755 label: options.label,
772 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),756 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),
773 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),757 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
...@@ -776,10 +760,22 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary...@@ -776,10 +760,22 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
776 environmentId,760 environmentId,
777 input,761 input,
778 };762 };
779 return (options.execute?.(input) ?? request(options.tag, input)).pipe(763 // Routing requires consent on the origin as well as the actual destination.
764 // The transport check below deliberately checks the destination again.
765 return permissions.authorize(registry, environmentId, input).pipe(
766 Effect.andThen(() => options.execute?.(input) ?? requestGuarded(options.tag, input)),
767 Effect.provideService(RpcPermissionGuard, {
768 authorize: (id, method, payload) =>
769 createCommandPermissions(runtime, method).authorize(registry, id, payload),
770 }),
780 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),771 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),
781 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),772 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),
782 );773 );
783 },774 },
784 });775 });
776 return {
777 ...command,
778 requiredScopes: permissions.requiredScopes,
779 permissionAtom: permissions.permissionAtom,
780 };
785}781}
packages/client-runtime/src/state/session.ts+30-5
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";1import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";5import * as Stream from "effect/Stream";
5import * as SubscriptionRef from "effect/SubscriptionRef";6import * as SubscriptionRef from "effect/SubscriptionRef";
6import type { HttpClient } from "effect/http";7import { HttpClient } from "effect/http";
7import { AsyncResult, Atom } from "effect/reactivity";8import { AsyncResult, Atom } from "effect/reactivity";
89
9import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";10import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
...@@ -14,7 +15,12 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";...@@ -14,7 +15,12 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";
14import * as ManagedRelay from "../relay/managedRelay.ts";15import * as ManagedRelay from "../relay/managedRelay.ts";
15import { safeErrorLogAttributes } from "../errors/safeLog.ts";16import { safeErrorLogAttributes } from "../errors/safeLog.ts";
16import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";17import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts";
17import { followStreamInEnvironment } from "./runtime.ts";18import { followStreamInEnvironment } from "./environmentStreams.ts";
19
20/** @public Required to name the error in consumers' inferred session results. */
21export class SessionHttpClientUnavailable extends Data.TaggedError(
22 "SessionHttpClientUnavailable",
23) {}
1824
19function initialConfigOption<E>(25function initialConfigOption<E>(
20 initialConfig: Effect.Effect<ServerConfig, E>,26 initialConfig: Effect.Effect<ServerConfig, E>,
...@@ -61,8 +67,8 @@ export const fetchEnvironmentSessionState = Effect.fn(...@@ -61,8 +67,8 @@ export const fetchEnvironmentSessionState = Effect.fn(
61 });67 });
62});68});
6369
64export function createEnvironmentSessionAtoms<R, E>(70function makeEnvironmentSessionAtoms<R, E>(
65 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | R, E>,71 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
66) {72) {
67 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>73 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
68 runtime.atom(74 runtime.atom(
...@@ -137,7 +143,13 @@ export function createEnvironmentSessionAtoms<R, E>(...@@ -137,7 +143,13 @@ export function createEnvironmentSessionAtoms<R, E>(
137 const remoteAuthorization = yield* Effect.serviceOption(143 const remoteAuthorization = yield* Effect.serviceOption(
138 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,144 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
139 );145 );
140 return yield* fetchEnvironmentSessionState({ prepared, signer, remoteAuthorization });146 const client = yield* Effect.serviceOption(HttpClient.HttpClient);
147 if (Option.isNone(client)) return yield* new SessionHttpClientUnavailable();
148 return yield* fetchEnvironmentSessionState({
149 prepared,
150 signer,
151 remoteAuthorization,
152 }).pipe(Effect.provideService(HttpClient.HttpClient, client.value));
141 });153 });
142 })154 })
143 .pipe(155 .pipe(
...@@ -163,3 +175,16 @@ export function createEnvironmentSessionAtoms<R, E>(...@@ -163,3 +175,16 @@ export function createEnvironmentSessionAtoms<R, E>(
163 sessionStateValueAtom,175 sessionStateValueAtom,
164 };176 };
165}177}
178
179const sessionAtomsByRuntime = new WeakMap<object, ReturnType<typeof makeEnvironmentSessionAtoms>>();
180
181/** Commands and UI share one session fetch and the same reconnect invalidation. */
182export function createEnvironmentSessionAtoms<R, E>(
183 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
184): ReturnType<typeof makeEnvironmentSessionAtoms<R, E>> {
185 const existing = sessionAtomsByRuntime.get(runtime);
186 if (existing) return existing as ReturnType<typeof makeEnvironmentSessionAtoms<R, E>>;
187 const atoms = makeEnvironmentSessionAtoms(runtime);
188 sessionAtomsByRuntime.set(runtime, atoms);
189 return atoms;
190}
packages/client-runtime/src/state/usage.test.ts+2
...@@ -75,6 +75,8 @@ function harness(ids = ["a"]) {...@@ -75,6 +75,8 @@ function harness(ids = ["a"]) {
75 get(environmentId).query,75 get(environmentId).query,
76 refreshUsageRates: {76 refreshUsageRates: {
77 label: "test:rates",77 label: "test:rates",
78 requiredScopes: () => [],
79 permissionAtom: () => Atom.make(true),
78 run: (80 run: (
79 _registry: AtomRegistry.AtomRegistry,81 _registry: AtomRegistry.AtomRegistry,
80 { environmentId }: { environmentId: EnvironmentId },82 { environmentId }: { environmentId: EnvironmentId },
packages/contracts/src/auth.ts+11
...@@ -80,6 +80,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;...@@ -80,6 +80,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
8080
81export const AuthOrchestrationReadScope = "orchestration:read" as const;81export const AuthOrchestrationReadScope = "orchestration:read" as const;
82export const AuthOrchestrationOperateScope = "orchestration:operate" as const;82export const AuthOrchestrationOperateScope = "orchestration:operate" as const;
83export const AuthSettingsWriteScope = "settings:write" as const;
84export const AuthProvidersManageScope = "providers:manage" as const;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
83export const AuthTerminalOperateScope = "terminal:operate" as const;86export const AuthTerminalOperateScope = "terminal:operate" as const;
84export const AuthReviewWriteScope = "review:write" as const;87export const AuthReviewWriteScope = "review:write" as const;
85export const AuthAccessReadScope = "access:read" as const;88export const AuthAccessReadScope = "access:read" as const;
...@@ -89,6 +92,9 @@ export const AuthRelayWriteScope = "relay:write" as const;...@@ -89,6 +92,9 @@ export const AuthRelayWriteScope = "relay:write" as const;
89export const AuthEnvironmentScope = Schema.Literals([92export const AuthEnvironmentScope = Schema.Literals([
90 AuthOrchestrationReadScope,93 AuthOrchestrationReadScope,
91 AuthOrchestrationOperateScope,94 AuthOrchestrationOperateScope,
95 AuthSettingsWriteScope,
96 AuthProvidersManageScope,
97 AuthEnvironmentMaintainScope,
92 AuthTerminalOperateScope,98 AuthTerminalOperateScope,
93 AuthReviewWriteScope,99 AuthReviewWriteScope,
94 AuthAccessReadScope,100 AuthAccessReadScope,
...@@ -103,6 +109,9 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;...@@ -103,6 +109,9 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
103export const AuthStandardClientScopes = [109export const AuthStandardClientScopes = [
104 AuthOrchestrationReadScope,110 AuthOrchestrationReadScope,
105 AuthOrchestrationOperateScope,111 AuthOrchestrationOperateScope,
112 AuthSettingsWriteScope,
113 AuthProvidersManageScope,
114 AuthEnvironmentMaintainScope,
106 AuthTerminalOperateScope,115 AuthTerminalOperateScope,
107 AuthReviewWriteScope,116 AuthReviewWriteScope,
108 AuthRelayReadScope,117 AuthRelayReadScope,
...@@ -145,6 +154,8 @@ export const ServerAuthDescriptor = Schema.Struct({...@@ -145,6 +154,8 @@ export const ServerAuthDescriptor = Schema.Struct({
145 bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod),154 bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod),
146 sessionMethods: Schema.Array(ServerAuthSessionMethod),155 sessionMethods: Schema.Array(ServerAuthSessionMethod),
147 sessionCookieName: TrimmedNonEmptyString,156 sessionCookieName: TrimmedNonEmptyString,
157 /** Older servers omit this and authorize self-updates with orchestration:operate. */
158 serverUpdateScope: Schema.optionalKey(Schema.Literal(AuthEnvironmentMaintainScope)),
148});159});
149export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;160export type ServerAuthDescriptor = typeof ServerAuthDescriptor.Type;
150161
packages/contracts/src/clientRpcPermissions.ts created+21
...@@ -0,0 +1,21 @@
1import { AuthOrchestrationOperateScope, type AuthEnvironmentScope } from "./auth.ts";
2import { WS_METHODS } from "./rpc.ts";
3
4/** Incremental client enforcement; the server still authorizes every request. */
5export const CLIENT_GUARDED_RPC_SCOPES = {
6 [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope,
7 [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope,
8 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
9 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
10 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
11} as const;
12export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
13
14export function clientRpcRequiredScopes(
15 method: string,
16 _input: unknown,
17): readonly AuthEnvironmentScope[] {
18 return Object.hasOwn(CLIENT_GUARDED_RPC_SCOPES, method)
19 ? [CLIENT_GUARDED_RPC_SCOPES[method as ClientGuardedRpcTag]]
20 : [];
21}
packages/contracts/src/index.ts+1
...@@ -62,3 +62,4 @@ export * from "./resourceTelemetry.ts";...@@ -62,3 +62,4 @@ export * from "./resourceTelemetry.ts";
62export * from "./rpc.ts";62export * from "./rpc.ts";
63export * from "./worktreeSetup.ts";63export * from "./worktreeSetup.ts";
64export * from "./secretRequest.ts";64export * from "./secretRequest.ts";
65export * from "./clientRpcPermissions.ts";
packages/contracts/src/settings.ts+25
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { SshDeviceHostConfigs } from "./device.ts";1import { SshDeviceHostConfigs } from "./device.ts";
2import {
3 AuthSettingsWriteScope,
4 AuthProvidersManageScope,
5 type AuthEnvironmentScope,
6} from "./auth.ts";
2import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
3import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
4import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
...@@ -1790,6 +1795,26 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1790,6 +1795,26 @@ export const ServerSettingsPatch = Schema.Struct({
1790});1795});
1791export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;1796export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
17921797
1798/** A mixed settings patch must be authorized for every configuration domain it changes. */
1799export function requiredScopesForServerSettingsPatch(
1800 patch: ServerSettingsPatch,
1801): ReadonlyArray<AuthEnvironmentScope> {
1802 let changesProviders = false;
1803 let changesSettings = false;
1804 for (const [key, value] of Object.entries(patch)) {
1805 if (value === undefined) continue;
1806 if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") {
1807 changesProviders = true;
1808 } else {
1809 changesSettings = true;
1810 }
1811 }
1812 return [
1813 ...(changesSettings || !changesProviders ? [AuthSettingsWriteScope] : []),
1814 ...(changesProviders ? [AuthProvidersManageScope] : []),
1815 ];
1816}
1817
1793export const ClientSettingsPatch = Schema.Struct({1818export const ClientSettingsPatch = Schema.Struct({
1794 notificationMode: Schema.optionalKey(NotificationMode),1819 notificationMode: Schema.optionalKey(NotificationMode),
1795 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),1820 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
vite.config.ts+41
...@@ -169,12 +169,53 @@ export default defineConfig({...@@ -169,12 +169,53 @@ export default defineConfig({
169 "t3code/no-native-title-tooltip": "error",169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-raw-mcp-registration": "error",170 "t3code/no-raw-mcp-registration": "error",
171 "t3code/no-test-in-loop": "error",171 "t3code/no-test-in-loop": "error",
172 "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }],
172 "t3code/no-unscoped-has": "error",173 "t3code/no-unscoped-has": "error",
173 "t3code/namespace-node-imports": "error",174 "t3code/namespace-node-imports": "error",
174 "t3code/prefer-catch-tags": "error",175 "t3code/prefer-catch-tags": "error",
175 "t3code/require-suppression-reason": "error",176 "t3code/require-suppression-reason": "error",
176 },177 },
177 overrides: [178 overrides: [
179 {
180 files: ["packages/client-runtime/src/state/**", "apps/{web,mobile,desktop}/src/**"],
181 rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: false }] },
182 },
183 {
184 // Only shared command boundaries install the session-backed permission guard.
185 files: [
186 "packages/client-runtime/src/state/runtime.ts",
187 "packages/client-runtime/src/state/vcsAction.ts",
188 ],
189 rules: {
190 "t3code/no-rpc-permission-bypass": [
191 "error",
192 { allowGuardInstallation: true, allowRawClientAccess: false },
193 ],
194 },
195 },
196 {
197 // These clients are session metadata, device streams, and an Expo update adapter.
198 files: [
199 "apps/web/src/components/settings/ConnectionsSettings.tsx",
200 "apps/mobile/src/features/updates/app-updates.ts",
201 "apps/web/src/components/device/DevicePhoneViewport.tsx",
202 "apps/web/src/components/device/DeviceDuoViewport.tsx",
203 ],
204 rules: { "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }] },
205 },
206 {
207 // Incompatible hosts cannot open a normal session; their updater uses a dedicated socket.
208 files: ["packages/client-runtime/src/connection/outdatedHostUpdate.ts"],
209 rules: { "t3code/no-rpc-permission-bypass": "off" },
210 },
211 {
212 // RPC implementation and transport test fixtures need the raw client.
213 files: [
214 "packages/client-runtime/src/rpc/**",
215 "**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
216 ],
217 rules: { "t3code/no-rpc-permission-bypass": "off" },
218 },
178 {219 {
179 // The one place that reads the host platform to seed the injected references.220 // The one place that reads the host platform to seed the injected references.
180 files: ["packages/shared/src/hostProcess.ts"],221 files: ["packages/shared/src/hostProcess.ts"],