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/
150150## Taste
151151
152152- 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.
153154- 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).
154155- `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).
155156- 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", () => ({
2525 Platform: { OS: "ios" },
2626}));
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")>()),
2936 linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),
3037}));
3138
......@@ -78,6 +85,7 @@ describe("liveActivityPreferences", () => {
7885 previousEnabled: true,
7986 clerkToken: "clerk-token",
8087 connections: [connection],
88 canConfigureEnvironment: () => true,
8189 });
8290
8391 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
......@@ -98,6 +106,7 @@ describe("liveActivityPreferences", () => {
98106 previousEnabled: false,
99107 clerkToken: "clerk-token",
100108 connections: [connection],
109 canConfigureEnvironment: () => true,
101110 });
102111
103112 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
......@@ -118,6 +127,7 @@ describe("liveActivityPreferences", () => {
118127 previousEnabled: true,
119128 clerkToken: null,
120129 connections: [connection],
130 canConfigureEnvironment: () => true,
121131 });
122132
123133 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
......@@ -139,6 +149,7 @@ describe("liveActivityPreferences", () => {
139149 previousEnabled: false,
140150 clerkToken: "clerk-token",
141151 connections: [connection, managedConnection],
152 canConfigureEnvironment: () => true,
142153 });
143154
144155 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
......@@ -162,6 +173,7 @@ describe("liveActivityPreferences", () => {
162173 previousEnabled: true,
163174 clerkToken: "clerk-token",
164175 connections: [connection],
176 canConfigureEnvironment: () => true,
165177 }),
166178 );
167179
......@@ -184,4 +196,105 @@ describe("liveActivityPreferences", () => {
184196 });
185197 }).pipe(Effect.provide(layerTest));
186198 });
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 });
187300});
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+18-11
......@@ -1,4 +1,5 @@
11import * as Effect from "effect/Effect";
2import type { EnvironmentId } from "@t3tools/contracts";
23
34import type { SavedRemoteConnection } from "../../lib/connection";
45import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
......@@ -10,11 +11,26 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
1011 readonly previousEnabled: boolean;
1112 readonly clerkToken: string | null;
1213 readonly connections: ReadonlyArray<SavedRemoteConnection>;
14 readonly canConfigureEnvironment: (environmentId: EnvironmentId) => boolean;
1315 }) {
1416 const linkedConnections = input.connections.filter(
1517 (connection) => connection.bearerToken !== null,
1618 );
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
1834 const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {
1935 yield* updateAgentAwarenessRegistrationPreferences({
2036 liveActivitiesEnabled: enabled,
......@@ -25,12 +41,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
2541
2642 yield* Effect.forEach(
2743 linkedConnections,
28 (connection) =>
29 linkEnvironmentToCloudWithPreference({
30 clerkToken,
31 connection,
32 liveActivitiesEnabled: enabled,
33 }),
44 (connection) => updateEnvironmentPreference(connection, enabled, clerkToken),
3445 { concurrency: "unbounded" },
3546 );
3647 });
......@@ -50,11 +61,7 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn
5061 yield* Effect.forEach(
5162 linkedConnections,
5263 (connection) =>
53 linkEnvironmentToCloudWithPreference({
54 clerkToken,
55 connection,
56 liveActivitiesEnabled: input.previousEnabled,
57 }).pipe(
64 updateEnvironmentPreference(connection, input.previousEnabled, clerkToken).pipe(
5865 Effect.catchCause((cause) =>
5966 Effect.logWarning(
6067 `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 {
44} from "@t3tools/client-runtime/state/shell";
55import { LegendList } from "@legendapp/list/react-native";
66import {
7 AuthOrchestrationOperateScope,
78 type EnvironmentId,
89 type EnvironmentMachineKind,
910 resolveEnvironmentMachineKind,
......@@ -30,6 +31,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
3031import { relativeTime } from "../../lib/time";
3132import { useUniwindTheme } from "../../lib/useUniwindTheme";
3233import { useServerConfigs } from "../../state/entities";
34import { useEnvironmentScope } from "../../state/session";
3335import { ThreadSwipeable } from "../home/thread-swipe-actions";
3436import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
3537import { SettingsScreenContent } from "../settings/components/SettingsScreen";
......@@ -191,24 +193,74 @@ function ArchivedThreadRow(props: {
191193}) {
192194 const { width: windowWidth } = useWindowDimensions();
193195 const cardColor = useUniwindTheme()["--color-grouped-card"];
196 const canOperateThread = useEnvironmentScope(
197 props.thread.environmentId,
198 AuthOrchestrationOperateScope,
199 );
194200 const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
195201 const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
196202 Boolean(part),
197203 );
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
198258 return (
199259 <ThreadSwipeable
200260 resetKey={`${props.thread.environmentId}:${props.thread.id}`}
201261 threadKey={`${props.thread.environmentId}:${props.thread.id}`}
202262 backgroundColor={cardColor}
203 // Round + clip the swipeable container so the group's corners stay
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 }}
263 containerStyle={containerStyle}
212264 fullSwipeWidth={windowWidth - 32}
213265 onDelete={props.onDelete}
214266 onSwipeableClose={props.onSwipeableClose}
......@@ -222,50 +274,7 @@ function ArchivedThreadRow(props: {
222274 simultaneousWith={props.simultaneousSwipeGesture}
223275 threadTitle={props.thread.title}
224276 >
225 {() => (
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 )}
277 {() => rowContent}
269278 </ThreadSwipeable>
270279 );
271280}
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 @@
11import type { ThreadMoveDestination } from "../threads/threadOrder";
22import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
33import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
4import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
45import * as Cause from "effect/Cause";
56import * as Haptics from "expo-haptics";
67import { useCallback, useRef } from "react";
......@@ -13,6 +14,7 @@ import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThre
1314import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
1415import { appAtomRegistry } from "../../state/atom-registry";
1516import { environmentServerConfigsAtom } from "../../state/server";
17import { readEnvironmentScope } from "../../state/session";
1618import { environmentThreadShells, threadEnvironment } from "../../state/threads";
1719import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
1820import { useAtomCommand } from "../../state/use-atom-command";
......@@ -108,6 +110,12 @@ function actionFailureTitle(action: ThreadListAction): string {
108110 return "Could not delete thread";
109111}
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
111119/** Resolves to true iff the action was dispatched and succeeded. */
112120function useThreadActionExecutor(
113121 onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void,
......@@ -121,6 +129,7 @@ function useThreadActionExecutor(
121129
122130 const executeAction = useCallback(
123131 async (action: ThreadListAction, thread: EnvironmentThreadShell) => {
132 if (!checkThreadOperationPermission(thread, actionFailureTitle(action))) return false;
124133 const key = scopedThreadKey(thread.environmentId, thread.id);
125134 if (inFlightThreadKeys.current.has(key)) {
126135 return false;
......@@ -205,6 +214,7 @@ function useConfirmDeleteThread(
205214) {
206215 return useCallback(
207216 (thread: EnvironmentThreadShell) => {
217 if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return;
208218 const title = "Delete thread?";
209219 const message = `“${thread.title}” will be permanently deleted, including its terminal history.`;
210220 if (process.env.EXPO_OS === "ios") {
......@@ -281,6 +291,7 @@ export function useThreadListActions(): {
281291 );
282292 const snoozeThread = useCallback(
283293 async (thread: EnvironmentThreadShell, snoozedUntil: string) => {
294 if (!checkThreadOperationPermission(thread, "Could not snooze thread")) return false;
284295 const key = scopedThreadKey(thread.environmentId, thread.id);
285296 if (snoozeInFlightThreadKeys.current.has(key)) {
286297 return false;
......@@ -336,6 +347,7 @@ export function useThreadListActions(): {
336347 );
337348 const unsnoozeThread = useCallback(
338349 async (thread: EnvironmentThreadShell) => {
350 if (!checkThreadOperationPermission(thread, "Could not wake thread")) return false;
339351 const key = scopedThreadKey(thread.environmentId, thread.id);
340352 if (snoozeInFlightThreadKeys.current.has(key)) {
341353 return false;
......@@ -383,6 +395,7 @@ export function useThreadListActions(): {
383395 );
384396 const pinThread = useCallback(
385397 async (thread: EnvironmentThreadShell) => {
398 if (!checkThreadOperationPermission(thread, "Could not pin thread")) return false;
386399 if (!environmentSupportsPinning(thread.environmentId)) {
387400 Alert.alert(
388401 "Could not pin thread",
......@@ -423,6 +436,7 @@ export function useThreadListActions(): {
423436 );
424437 const unpinThread = useCallback(
425438 async (thread: EnvironmentThreadShell) => {
439 if (!checkThreadOperationPermission(thread, "Could not unpin thread")) return false;
426440 if (!environmentSupportsPinning(thread.environmentId)) {
427441 Alert.alert(
428442 "Could not unpin thread",
......@@ -479,6 +493,7 @@ export function useThreadListActions(): {
479493 );
480494 const regenerateThreadTitle = useCallback(
481495 async (thread: EnvironmentThreadShell) => {
496 if (!checkThreadOperationPermission(thread, "Could not regenerate title")) return false;
482497 const key = scopedThreadKey(thread.environmentId, thread.id);
483498 if (
484499 thread.titleRegeneration != null ||
......@@ -572,6 +587,7 @@ export function useThreadListActions(): {
572587 });
573588 const moveThread = useCallback(
574589 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
590 if (!checkThreadOperationPermission(thread, "Could not move thread")) return false;
575591 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
576592 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
577593 const current = shells.find(
......@@ -648,6 +664,11 @@ export function useThreadListActions(): {
648664 const shellByKey = new Map(
649665 shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
650666 );
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 }
651672 selectionHaptic();
652673 appAtomRegistry.set(threadDropBusyAtom, true);
653674 const pending = crossSection
......@@ -694,6 +715,7 @@ export function useThreadListActions(): {
694715 if (pending !== null && !pending.isPending()) return false;
695716 const target = shellByKey.get(assignment.id);
696717 if (target === undefined) continue;
718 if (!checkThreadOperationPermission(target, "Could not move thread")) return false;
697719 const result = await reorder({
698720 environmentId: target.environmentId,
699721 input: { threadId: target.id, orderKey: assignment.orderKey },
apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx+33-7
......@@ -1,6 +1,11 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
49import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
510import { AsyncResult } from "effect/reactivity";
611import { useEffect, useRef, useState } from "react";
......@@ -10,8 +15,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1015import { AppText as Text } from "../../components/AppText";
1116import { ProviderIcon } from "../../components/ProviderIcon";
1217import { ScreenScrollView } from "../../components/ScreenScrollView";
18import { appAtomRegistry } from "../../state/atom-registry";
1319import { serverEnvironment } from "../../state/server";
14import { environmentSession } from "../../state/session";
20import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session";
1521import { useAtomCommand } from "../../state/use-atom-command";
1622import { useRemoteConnections } from "../../state/use-remote-environment-registry";
1723import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
......@@ -75,7 +81,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
7581 (provider) =>
7682 provider.updateState?.status === "running" || provider.updateState?.status === "queued",
7783 ) ?? 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;
7988 const version = config?.environment.serverVersion;
8089 const checkedRelease = release?.fromVersion === version ? release : null;
8190 const capabilities = config?.environment.capabilities;
......@@ -111,6 +120,16 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
111120 text: "Update",
112121 onPress: () =>
113122 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;
114133 const result = await updateServer({
115134 environmentId,
116135 input: {
......@@ -131,7 +150,12 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
131150 }
132151
133152 function requestProviderUpdate(provider: ServerProvider) {
134 if (disabled || !canUpdateEnvironmentProvider(provider)) return;
153 if (
154 busy ||
155 !readEnvironmentScope(environmentId, AuthProvidersManageScope) ||
156 !canUpdateEnvironmentProvider(provider)
157 )
158 return;
135159 void run(provider.instanceId, async () => {
136160 const result = await updateProvider({
137161 environmentId,
......@@ -274,11 +298,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
274298 <SettingsActionRow
275299 icon="arrow.clockwise"
276300 label="Refresh providers"
277 disabled={disabled}
301 disabled={busy || !canRead}
278302 loading={pending === "refresh"}
279303 onPress={() => {
280 if (disabled) return;
304 if (busy || !canRead) return;
281305 void run("refresh", async () => {
306 if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
307 return;
282308 const result = await refreshProviders({ environmentId, input: {} });
283309 if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result);
284310 setNotice("Provider status refreshed.");
......@@ -338,7 +364,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
338364 <SettingsActionRow
339365 icon="arrow.up.circle"
340366 label={`Update ${provider.displayName ?? provider.driver}`}
341 disabled={disabled}
367 disabled={busy || !canManageProviders}
342368 loading={pending === provider.instanceId}
343369 onPress={() => requestProviderUpdate(provider)}
344370 />
apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx+7-1
......@@ -1,3 +1,5 @@
1import { AuthRelayWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
13import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
24import { useAuth } from "@clerk/expo";
35import { useAtomSet, useAtomValue } from "@effect/atom-react";
......@@ -271,6 +273,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {
271273 const updateResult = await settleAsyncResult(() =>
272274 runtime.runPromiseExit(
273275 setLiveActivityUpdatesEnabled({
276 canConfigureEnvironment: (environmentId) =>
277 readEnvironmentScope(environmentId, AuthRelayWriteScope),
274278 enabled: true,
275279 previousEnabled: liveActivitiesPreferenceEnabled,
276280 clerkToken: tokenResult.value,
......@@ -302,7 +306,7 @@ function ConfiguredSettingsNotificationsRouteScreen() {
302306 Alert.alert(
303307 Platform.OS === "android" ? "Ongoing activity enabled" : "Live Activities enabled",
304308 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."
306310 : "Agent activity updates are enabled. Add an environment to start receiving updates.",
307311 );
308312 } else {
......@@ -370,6 +374,8 @@ function ConfiguredSettingsNotificationsRouteScreen() {
370374 const updateResult = await settleAsyncResult(() =>
371375 runtime.runPromiseExit(
372376 setLiveActivityUpdatesEnabled({
377 canConfigureEnvironment: (environmentId) =>
378 readEnvironmentScope(environmentId, AuthRelayWriteScope),
373379 enabled: false,
374380 previousEnabled: liveActivitiesPreferenceEnabled,
375381 clerkToken: token,
apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts+60-1
......@@ -1,6 +1,65 @@
1import { EnvironmentId } from "@t3tools/contracts";
12import { 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
564describe("resolveAgentAwarenessPlatformPresentation", () => {
665 it("supports agent awareness settings on Android", () => {
apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts+16
......@@ -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
117export function resolveAgentAwarenessPlatformPresentation(platform: string): {
218 readonly supported: boolean;
319 readonly subtitle: string | undefined;
apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx+28-6
......@@ -1,3 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { readEnvironmentScope } from "../../state/session";
14import type {
25 EnvironmentId,
36 ProjectId,
......@@ -580,6 +583,9 @@ function TaskForm({
580583 const projects = useProjects().filter((project) => project.environmentId === environmentId);
581584 const config = useEnvironmentServerConfig(environmentId);
582585 const modelOptions = useMemo(() => buildModelOptions(config, null), [config]);
586 const canOperate = useAtomValue(
587 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
588 );
583589 const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, {
584590 label: "scheduled task upsert",
585591 reportFailure: false,
......@@ -602,6 +608,7 @@ function TaskForm({
602608
603609 const save = async () => {
604610 if (
611 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
605612 submissionPending.current ||
606613 saving ||
607614 dictationPending ||
......@@ -937,7 +944,7 @@ function TaskForm({
937944 : draft.task
938945 }
939946 signatureConfigured={draft.schedule.signature !== null}
940 disabled={saving || environmentUnavailable}
947 disabled={!canOperate || saving || environmentUnavailable}
941948 />
942949 <FormField
943950 label="Skip requests older than (minutes)"
......@@ -994,7 +1001,9 @@ function TaskForm({
9941001 accessibilityState={{
9951002 disabled: saving || dictationPending || taskMissing || environmentUnavailable,
9961003 }}
997 disabled={saving || dictationPending || taskMissing || environmentUnavailable}
1004 disabled={
1005 !canOperate || saving || dictationPending || taskMissing || environmentUnavailable
1006 }
9981007 onPress={() => void save()}
9991008 className="min-h-12 items-center justify-center rounded-[14px] bg-primary px-4 disabled:opacity-50"
10001009 >
......@@ -1118,6 +1127,9 @@ function EnvironmentTasks({
11181127 const visibleTasks = tasks.data?.tasks.filter(
11191128 (task) => projectIds === null || projectIds.includes(task.projectId),
11201129 );
1130 const canOperate = useAtomValue(
1131 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
1132 );
11211133 const setEnabled = useAtomCommand(serverEnvironment.setScheduledTaskEnabled, {
11221134 label: "scheduled task enabled",
11231135 reportFailure: false,
......@@ -1137,6 +1149,7 @@ function EnvironmentTasks({
11371149 };
11381150
11391151 const act = async (task: ScheduledTask, action: "run" | "toggle" | "delete") => {
1152 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
11401153 const result =
11411154 action === "run"
11421155 ? await runNow({ environmentId, input: { id: task.id } })
......@@ -1205,10 +1218,19 @@ function EnvironmentTasks({
12051218 <ControlPillMenu
12061219 actions={[
12071220 { id: "edit", title: "Edit" },
1208 { id: "toggle", title: task.enabled ? "Pause" : "Resume" },
1209 // A webhook task has no request to run without.
1210 ...(task.schedule.type === "webhook" ? [] : [{ id: "run", title: "Run now" }]),
1211 { id: "delete", title: "Delete", attributes: { destructive: true } },
1221 {
1222 id: "toggle",
1223 title: task.enabled ? "Pause" : "Resume",
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 },
12121234 ]}
12131235 onPressAction={({ nativeEvent }) => {
12141236 const action = nativeEvent.event;
apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx+26-3
......@@ -1,5 +1,7 @@
11import { useNavigation } from "@react-navigation/native";
22import { SettingsRow } from "./components/SettingsRow";
3import { AuthSettingsWriteScope } from "@t3tools/contracts";
4import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
35import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
46import { AppText as Text } from "../../components/AppText";
57import {
......@@ -141,6 +143,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
141143 const navigation = useNavigation();
142144 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
143145 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
146 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
144147 const projectSelected = selectedProjectKey !== null;
145148 const targets = resolveMobileSettingsTargets(
146149 selectedTargets,
......@@ -151,6 +154,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
151154 const [pendingTargets, setPendingTargets] = useState<
152155 readonly ScopedMobileSettingsTarget[] | null
153156 >(null);
157 const canWriteSettings =
158 targets.length > 0 &&
159 targets.every((target) => writableEnvironments.has(target.environment.environmentId));
154160 const displayTargets = pendingWrites > 0 && pendingTargets !== null ? pendingTargets : targets;
155161 const hasConnectedSelection = targets.length > 0;
156162 const reference = displayTargets[0] ?? null;
......@@ -165,7 +171,14 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
165171 reportFailure: true,
166172 });
167173 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;
169182 const writes = planMobileScopedSettingsPatch(targets, projectSelected, patch);
170183 if (writes.length === 0) return;
171184 writeInFlight.current = true;
......@@ -182,7 +195,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
182195 });
183196 };
184197 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;
186205 const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]);
187206 if (writes.length === 0) return;
188207 writeInFlight.current = true;
......@@ -203,7 +222,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
203222 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
204223 );
205224 const disabled =
206 pendingWrites > 0 || !hasConnectedSelection || (projectSelected && !supportsProjectOverrides);
225 !canWriteSettings ||
226 pendingWrites > 0 ||
227 !hasConnectedSelection ||
228 (projectSelected && !supportsProjectOverrides);
207229 const supportsContinuation = targets.every(
208230 (target) =>
209231 target.environment.serverConfig.environment.capabilities.threadRestartContinuation === true,
......@@ -245,6 +267,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
245267 )}
246268 supportsOverrides={supportsProjectOverrides}
247269 pending={pendingWrites > 0}
270 disabled={!canWriteSettings}
248271 onClear={clearProjectOverrides}
249272 />
250273 ) : null}
apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx+23-3
......@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
13import { AutoSettleDaysField } from "./components/AutoSettleDaysField";
24import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
35import { useAtomSet, useAtomValue } from "@effect/atom-react";
......@@ -61,6 +63,7 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD
6163function AutoSettleSettingsRows() {
6264 const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter();
6365 const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey);
66 const writableEnvironments = useEnvironmentsWithScope(selectedTargets, AuthSettingsWriteScope);
6467 const projectSelected = selectedProjectKey !== null;
6568 const [pendingWrites, setPendingWrites] = useState(0);
6669 const writeInFlight = useRef(false);
......@@ -77,6 +80,9 @@ function AutoSettleSettingsRows() {
7780 syncEnvironments,
7881 projectSelected ? (selectedProject?.members.map((member) => member.project) ?? []) : null,
7982 );
83 const canWriteSettings =
84 syncTargets.length > 0 &&
85 syncTargets.every((target) => writableEnvironments.has(target.environment.environmentId));
8086 const displayTargets =
8187 pendingWrites > 0 && pendingTargets !== null ? pendingTargets : syncTargets;
8288 const reference = displayTargets[0] ?? null;
......@@ -92,7 +98,13 @@ function AutoSettleSettingsRows() {
9298 snoozeLimitedThreads?: boolean;
9399 },
94100 ) => {
95 if (writeInFlight.current) return;
101 if (
102 writeInFlight.current ||
103 !syncTargets.every((target) =>
104 readEnvironmentScope(target.environment.environmentId, AuthSettingsWriteScope),
105 )
106 )
107 return;
96108 const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch);
97109 if (writes.length === 0) return;
98110 writeInFlight.current = true;
......@@ -127,7 +139,8 @@ function AutoSettleSettingsRows() {
127139 (target) =>
128140 target.environment.serverConfig.environment.capabilities.projectSettingsOverrides === true,
129141 );
130 const disabled = pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
142 const disabled =
143 !canWriteSettings || pendingWrites > 0 || (projectSelected && !supportsProjectOverrides);
131144 const hasProjectOverrides =
132145 projectSelected &&
133146 syncTargets.some(
......@@ -136,7 +149,13 @@ function AutoSettleSettingsRows() {
136149 target.sources.sidebarAutoSettleAfterDays === "project",
137150 );
138151 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;
140159 const writes = planMobileScopedSettingsClear(syncTargets, [
141160 "sidebarAutoSettleOnMerge",
142161 "sidebarAutoSettleAfterDays",
......@@ -166,6 +185,7 @@ function AutoSettleSettingsRows() {
166185 hasOverrides={hasProjectOverrides}
167186 supportsOverrides={supportsProjectOverrides}
168187 pending={pendingWrites > 0}
188 disabled={!canWriteSettings}
169189 onClear={clearProjectOverrides}
170190 />
171191 ) : null}
apps/mobile/src/features/settings/components/SettingsProjectOverridesSection.tsx+2
......@@ -8,6 +8,7 @@ export function SettingsProjectOverridesSection(props: {
88 readonly hasOverrides: boolean;
99 readonly supportsOverrides: boolean;
1010 readonly pending: boolean;
11 readonly disabled?: boolean;
1112 readonly onClear: () => void;
1213}) {
1314 return (
......@@ -20,6 +21,7 @@ export function SettingsProjectOverridesSection(props: {
2021 <Pressable
2122 accessibilityRole="button"
2223 accessibilityLabel="Use environment defaults"
24 disabled={props.disabled}
2325 onPress={props.onClear}
2426 className="px-2 py-2 active:opacity-70"
2527 >
apps/mobile/src/features/settings/environment-maintenance.test.ts+23
......@@ -53,6 +53,29 @@ describe("environment maintenance access", () => {
5353 expect(canMaintainEnvironment(null, true)).toBe(false);
5454 });
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
5679 it("requires remote desktop update support for desktop hosts", () => {
5780 expect(supportsEnvironmentUpdate({})).toBe(false);
5881 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
1616 return (
1717 connected &&
1818 session?.authenticated === true &&
19 session.scopes?.includes(AuthOrchestrationOperateScope) === true
19 session.scopes?.includes(session.auth.serverUpdateScope ?? AuthOrchestrationOperateScope) ===
20 true
2021 );
2122}
2223
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+20
......@@ -28,6 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2828import { useUniwindTheme } from "../../lib/useUniwindTheme";
2929import { useFontFamily } from "../../lib/useFontFamily";
3030import {
31 AuthOrchestrationOperateScope,
3132 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
3233 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3334 resolveEnvironmentMachineKind,
......@@ -133,6 +134,7 @@ import { selectIncomingShareAttachmentsForServer } from "../sharing/incoming-sha
133134import { appAtomRegistry } from "../../state/atom-registry";
134135import { serverEnvironment } from "../../state/server";
135136import { fileRoutePathSegments } from "../files/filePath";
137import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
136138
137139function NewTaskWorkspaceIcon(props: {
138140 readonly workspaceMode: "local" | "worktree";
......@@ -219,6 +221,12 @@ export function NewTaskDraftScreen(props: {
219221 connectedEnvironments.find(
220222 (environment) => environment.environmentId === selectedProject.environmentId,
221223 )?.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;
222230 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
223231 // A project added by cloning exists before its files do: the prompt can be
224232 // written meanwhile, but Start waits for the clone.
......@@ -1214,6 +1222,12 @@ export function NewTaskDraftScreen(props: {
12141222 if (!selectedProject || !draftKey) {
12151223 return;
12161224 }
1225 if (
1226 environmentConnected &&
1227 !readEnvironmentScope(selectedProject.environmentId, AuthOrchestrationOperateScope)
1228 ) {
1229 return;
1230 }
12171231 const draft = getComposerDraftSnapshot(draftKey);
12181232 if (appAtomRegistry.get(composerContextImportsAtom)[draftKey]) return;
12191233 // Read the latest explicit pick. Antigravity selections stay unchanged
......@@ -1378,6 +1392,7 @@ export function NewTaskDraftScreen(props: {
13781392 const canStart =
13791393 !isImportingContext &&
13801394 !cloneBlocksStart &&
1395 taskPermissionReason === null &&
13811396 attachmentBlockReason === null &&
13821397 !modelUnavailable &&
13831398 Boolean(flow.selectedProject) &&
......@@ -1655,6 +1670,10 @@ export function NewTaskDraftScreen(props: {
16551670 ) : null}
16561671 {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
16581677 {modelUnavailable ? (
16591678 <Pressable
16601679 accessibilityRole="button"
......@@ -1794,6 +1813,7 @@ export function NewTaskDraftScreen(props: {
17941813 {voicePresentation.showsSend ? (
17951814 <ComposerActionButton
17961815 accessibilityLabel={
1816 taskPermissionReason ??
17971817 attachmentBlockReason ??
17981818 (cloneBlocksStart
17991819 ? 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";
1010import type { PendingApproval } from "../../lib/threadActivity";
1111
1212export interface PendingApprovalCardProps {
13 readonly canOperateThread: boolean;
1314 readonly approval: PendingApproval;
1415 readonly respondingApprovalId: RuntimeRequestId | null;
1516 readonly onRespond: (
......@@ -66,11 +67,16 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
6667 ? "danger"
6768 : "secondary"
6869 }
69 disabled={disabled}
70 disabled={disabled || !props.canOperateThread}
7071 onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
7172 />
7273 ))}
7374 </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}
7480 </View>
7581 );
7682}
apps/mobile/src/features/threads/PendingUserInputCard.tsx+8-1
......@@ -28,6 +28,7 @@ import {
2828} from "../../lib/threadActivity";
2929
3030export interface PendingUserInputCardProps {
31 readonly canOperateThread: boolean;
3132 readonly pendingUserInput: PendingUserInput;
3233 /**
3334 * Constant while a request is pending (it reserves keyboard space), so the
......@@ -200,6 +201,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
200201 icon="stop.fill"
201202 variant="danger"
202203 className="h-9 w-9"
204 disabled={!props.canOperateThread}
203205 onPress={props.onStopThread}
204206 />
205207 ) : null}
......@@ -342,7 +344,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
342344 label="Submit answers"
343345 size="large"
344346 tone={props.answers ? "primary" : "secondary"}
345 disabled={responseDisabled || props.answers === null}
347 disabled={!props.canOperateThread || responseDisabled || props.answers === null}
346348 onPress={() => void props.onSubmit()}
347349 />
348350 {props.pendingUserInput.dismissible ? (
......@@ -357,6 +359,11 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
357359 </Text>
358360 </Pressable>
359361 ) : null}
362 {!props.canOperateThread ? (
363 <Text className="font-sans text-xs text-foreground-tertiary">
364 This connection cannot submit answers.
365 </Text>
366 ) : null}
360367 </Animated.View>
361368 ) : null;
362369 return (
apps/mobile/src/features/threads/ThreadComposer.tsx+10
......@@ -140,6 +140,7 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
140140export const COMPOSER_EXPANDED_CHROME = 156;
141141
142142export interface ThreadComposerProps {
143 readonly canOperateThread: boolean;
143144 readonly draftMessage: string;
144145 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
145146 readonly placeholder: string;
......@@ -539,6 +540,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
539540 (pendingPastedTextAttachmentCount > 0 ? "Attaching pasted text" : null) ??
540541 attachmentBlockReason;
541542 const canSend =
543 (props.canOperateThread || props.connectionState !== "connected") &&
542544 hasContent &&
543545 !contextImports[composerDraftKey] &&
544546 !voiceInput.blocksSubmission &&
......@@ -1056,6 +1058,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
10561058 accessibilityLabel="Stop agent"
10571059 icon="stop.fill"
10581060 variant="danger"
1061 disabled={!props.canOperateThread}
10591062 onPress={props.onStopThread}
10601063 />
10611064 ) : (
......@@ -1150,6 +1153,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
11501153 accessibilityLabel="Stop agent"
11511154 icon="stop.fill"
11521155 variant="danger"
1156 disabled={!props.canOperateThread}
11531157 onPress={props.onStopThread}
11541158 />
11551159 ) : voicePresentation.showsSend ? (
......@@ -1165,6 +1169,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
11651169 </ComposerDictationToolbar>
11661170 </Animated.View>
11671171 </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}
11681178 </Animated.View>
11691179
11701180 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4
......@@ -148,6 +148,7 @@ import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider";
148148export interface ThreadDetailScreenProps {
149149 readonly worktreeSetup?: WorktreeSetupCardProps | null;
150150 readonly setupWorkingStartedAt?: string | null;
151 readonly canOperateThread: boolean;
151152 readonly selectedThread: EnvironmentThreadShell;
152153 readonly contentPresentation: ThreadContentPresentation;
153154 readonly screenTone: StatusTone;
......@@ -1297,6 +1298,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
12971298 >
12981299 {props.activePendingApproval ? (
12991300 <PendingApprovalCard
1301 canOperateThread={props.canOperateThread}
13001302 approval={props.activePendingApproval}
13011303 respondingApprovalId={props.respondingApprovalId}
13021304 onRespond={props.onRespondToApproval}
......@@ -1304,6 +1306,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
13041306 ) : null}
13051307 {props.activePendingUserInput ? (
13061308 <PendingUserInputCard
1309 canOperateThread={props.canOperateThread}
13071310 pendingUserInput={props.activePendingUserInput}
13081311 maxHeight={pendingUserInputMaxHeight}
13091312 collapsed={userInputCollapsed}
......@@ -1371,6 +1374,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
13711374 ) : (
13721375 <>
13731376 <ThreadComposer
1377 canOperateThread={props.canOperateThread}
13741378 reportedModelSelection={reportedModelSelection}
13751379 editorRef={composerEditorRef}
13761380 draftMessage={props.draftMessage}
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+12-1
......@@ -15,11 +15,13 @@ import {
1515import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
1616import * as Option from "effect/Option";
1717import {
18 AuthOrchestrationOperateScope,
1819 DEFAULT_SERVER_SETTINGS,
1920 EnvironmentId,
2021 ThreadId,
2122 type ProjectScript,
2223} from "@t3tools/contracts";
24import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2325import {
2426 projectScriptCwd,
2527 projectScriptRuntimeEnv,
......@@ -333,6 +335,10 @@ function ThreadRouteContent(
333335 selectedThreadProject,
334336 selectedEnvironmentConnection,
335337 } = useThreadSelection();
338 const canOperateThread = useEnvironmentScope(
339 selectedThread?.environmentId ?? null,
340 AuthOrchestrationOperateScope,
341 );
336342 const selectedThreadDetailState = props.selectedThreadDetailState;
337343 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
338344 const { selectedThreadCwd } = useSelectedThreadWorktree();
......@@ -673,7 +679,11 @@ function ThreadRouteContent(
673679 void navigation.navigate("Connections");
674680 }, [navigation]);
675681 const handleStopThread = useCallback(() => {
676 if (!selectedThread || composer.interruptibleRunId === null) {
682 if (
683 !selectedThread ||
684 !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope) ||
685 composer.interruptibleRunId === null
686 ) {
677687 return;
678688 }
679689 return interruptThreadTurn({
......@@ -988,6 +998,7 @@ function ThreadRouteContent(
988998
989999 <View className="flex-1 bg-screen android:overflow-hidden android:rounded-t-[28px] android:bg-thread-canvas">
9901000 <ThreadDetailScreen
1001 canOperateThread={canOperateThread}
9911002 selectedThread={selectedThreadWithDraftSettings ?? selectedThread}
9921003 contentPresentation={contentPresentation}
9931004 screenTone={connectionTone(routeConnectionState)}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+8-3
......@@ -17,7 +17,7 @@ import type {
1717 EnvironmentThreadShell,
1818} from "@t3tools/client-runtime/state/shell";
1919import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
20import type { EnvironmentMachineKind } from "@t3tools/contracts";
20import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
2121import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
2222import type { MenuAction } from "@react-native-menu/menu";
2323import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
......@@ -34,6 +34,7 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco
3434import { cn } from "../../lib/cn";
3535import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
3636import { useUniwindTheme } from "../../lib/useUniwindTheme";
37import { useEnvironmentScope } from "../../state/session";
3738import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3839import { useThreadPr } from "../../state/use-thread-pr";
3940import { useSwipeRowDormant } from "../home/swipe-row-activation";
......@@ -600,6 +601,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
600601
601602 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
602603 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
604 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
603605 const handleRegenerateTitle = useCallback(
604606 () => onRegenerateThreadTitle(thread),
605607 [onRegenerateThreadTitle, thread],
......@@ -917,8 +919,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
917919 : null,
918920 [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title],
919921 );
920 const swipeAccessibilityHint =
921 secondaryAction === null
922 const swipeAccessibilityHint = !canOperateThread
923 ? "Opens the thread"
924 : secondaryAction === null
922925 ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.`
923926 : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`;
924927
......@@ -1224,6 +1227,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
12241227 </RowPressable>
12251228 );
12261229
1230 if (!canOperateThread) return rowContent(() => {});
1231
12271232 return (
12281233 <View collapsable={false}>
12291234 {customSnoozeOpen && (
apps/mobile/src/features/usage/UsageLimitsSection.test.ts+4
......@@ -9,6 +9,10 @@ const state = vi.hoisted(() => ({
99 autoRefresh: async () => {},
1010 refreshingRef: { current: false },
1111}));
12vi.mock("../../state/session", () => ({
13 useEnvironmentScope: () => true,
14 readEnvironmentScope: () => true,
15}));
1216vi.mock("react", () => ({
1317 useState: (initial: unknown) => {
1418 const index = state.cursor++;
apps/mobile/src/features/usage/UsageLimitsSection.tsx+22-12
......@@ -1,12 +1,13 @@
11import { useAtomValue } from "@effect/atom-react";
2import type {
3 EnvironmentId,
4 ProviderConsumeResetCreditOutcome,
5 ProviderConsumeResetCreditInput,
6 ServerProvider,
7 ServerProviderResetCredits,
8 ServerProviderUsageWindow,
9 UsageProviderKind,
2import {
3 AuthProvidersManageScope,
4 type EnvironmentId,
5 type ProviderConsumeResetCreditOutcome,
6 type ProviderConsumeResetCreditInput,
7 type ServerProvider,
8 type ServerProviderResetCredits,
9 type ServerProviderUsageWindow,
10 type UsageProviderKind,
1011} from "@t3tools/contracts";
1112import {
1213 elapsedShare,
......@@ -24,6 +25,7 @@ import { AppText as Text } from "../../components/AppText";
2425import { ProviderIcon } from "../../components/ProviderIcon";
2526import { environmentPresentations } from "../../state/presentation";
2627import { serverEnvironment } from "../../state/server";
28import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2729import { useAtomCommand } from "../../state/use-atom-command";
2830import { useProviderColors } from "./usageProviders";
2931
......@@ -207,6 +209,7 @@ export function ResetCredits(props: {
207209 readonly dense?: boolean;
208210}) {
209211 const { environmentId, input, credits, now, dense = false } = props;
212 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
210213 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
211214 reportFailure: false,
212215 });
......@@ -225,6 +228,7 @@ export function ResetCredits(props: {
225228 }`;
226229
227230 const redeem = async () => {
231 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
228232 setBusy(true);
229233 setStatus(null);
230234 const result = await consume({ environmentId, input });
......@@ -241,6 +245,7 @@ export function ResetCredits(props: {
241245 };
242246
243247 const confirm = () => {
248 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
244249 Alert.alert(
245250 "Use a reset credit?",
246251 "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: {
257262 {credits.availableCount > 0 ? (
258263 <Pressable
259264 accessibilityRole="button"
260 accessibilityState={{ disabled: busy }}
261 disabled={busy}
265 accessibilityState={{ disabled: busy || !canManageProviders }}
266 disabled={busy || !canManageProviders}
262267 onPress={confirm}
263268 className={
264269 dense
265 ? "rounded-full bg-subtle-strong px-2.5 py-1"
266 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5"
270 ? "rounded-full bg-subtle-strong px-2.5 py-1 disabled:opacity-[0.45]"
271 : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
267272 }
268273 >
269274 <Text
......@@ -277,6 +282,11 @@ export function ResetCredits(props: {
277282 </Text>
278283 </Pressable>
279284 ) : null}
285 {!canManageProviders ? (
286 <Text className="text-xs text-foreground-tertiary">
287 This connection cannot manage provider accounts.
288 </Text>
289 ) : null}
280290 {status ? <Text className="text-sm text-foreground">{status}</Text> : null}
281291 </View>
282292 );
apps/mobile/src/lib/attachmentUpload.test.ts+77
......@@ -3,6 +3,7 @@ import * as Option from "effect/Option";
33import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
55const mocks = vi.hoisted(() => ({
6 canOperate: true,
67 documentUri: "file:///documents",
78 createAssetUrl: vi.fn(),
89 createUploadUrl: Symbol("create-upload-url"),
......@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({
4849}));
4950
5051vi.mock("../state/session", () => ({
52 readEnvironmentScope: () => mocks.canOperate,
5153 environmentSession: {
5254 preparedConnectionValueAtom: () => mocks.preparedConnection,
5355 },
......@@ -194,6 +196,7 @@ function removeCallsFor(attachmentId: string): number {
194196
195197describe("prepareTurnAttachments", () => {
196198 beforeEach(() => {
199 mocks.canOperate = true;
197200 mocks.documentUri = "file:///documents";
198201 mocks.createAssetUrl.mockReset();
199202 mocks.createAssetUrl.mockImplementation((target: unknown) => target);
......@@ -222,6 +225,80 @@ describe("prepareTurnAttachments", () => {
222225 mocks.upload.mockResolvedValue({ status: 204, body: "", headers: {} });
223226 });
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
225302 it("keeps existing image attachments on the legacy wire path", async () => {
226303 const prepared = await prepareTurnAttachments({ environmentId, attachments: [image] });
227304
apps/mobile/src/lib/attachmentUpload.ts+23-2
......@@ -13,13 +13,16 @@ import type {
1313 EnvironmentId,
1414 UploadChatImageAttachment,
1515} 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";
1720import * as Option from "effect/Option";
1821
1922import { appAtomRegistry } from "../state/atom-registry";
2023import { assetEnvironment } from "../state/assets";
2124import { attachmentEnvironment } from "../state/attachments";
22import { environmentSession } from "../state/session";
25import { environmentSession, readEnvironmentScope } from "../state/session";
2326import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
2427import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention";
2528import {
......@@ -109,6 +112,9 @@ export async function releasePendingAttachmentUploads(
109112 attachmentIds: ReadonlyArray<string>,
110113): Promise<void> {
111114 const deleteOnce = async (attachmentId: string): Promise<boolean> => {
115 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
116 throw new Error("This connection cannot delete pending attachments.");
117 }
112118 const result = await runAtomCommand(
113119 appAtomRegistry,
114120 attachmentEnvironment.remove,
......@@ -255,6 +261,7 @@ async function composerImageAttachmentDataUrl(
255261}
256262
257263async function uploadFileBytes(
264 environmentId: EnvironmentId,
258265 attachment: DraftComposerAttachment,
259266 url: string,
260267 signal: AbortSignal,
......@@ -262,6 +269,9 @@ async function uploadFileBytes(
262269): Promise<void> {
263270 const { File, Paths, UploadType } = await import("expo-file-system");
264271 if (signal.aborted) throw new Error("Upload cancelled.");
272 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
273 throw new Error("This connection cannot upload attachments.");
274 }
265275 // Legacy image drafts persisted inline bytes and stage them in a temp cache
266276 // file for the native uploader. Everything else uploads its owned copy.
267277 const fileUri = attachment.fileUri;
......@@ -349,6 +359,13 @@ export async function prepareTurnAttachments(input: {
349359 }
350360 }
351361
362 const requireUploadAccess = () => {
363 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
364 throw new Error("This connection cannot upload attachments.");
365 }
366 };
367 requireUploadAccess();
368
352369 const connection = appAtomRegistry.get(
353370 environmentSession.preparedConnectionValueAtom(environmentId),
354371 );
......@@ -365,6 +382,7 @@ export async function prepareTurnAttachments(input: {
365382 try {
366383 for (const attachment of input.attachments) {
367384 if (controller.signal.aborted) throw new Error("Upload cancelled.");
385 requireUploadAccess();
368386 if (attachment.type === "image" && !input.supportsImageUploads) {
369387 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
370388 continue;
......@@ -393,6 +411,7 @@ export async function prepareTurnAttachments(input: {
393411 // "missing": the pending upload expired, upload the bytes again.
394412 }
395413
414 requireUploadAccess();
396415 const result = await runAttachmentUploadCycle({
397416 registry: appAtomRegistry,
398417 createUploadUrl: attachmentEnvironment.createUploadUrl,
......@@ -402,6 +421,7 @@ export async function prepareTurnAttachments(input: {
402421 // Read the connection at transfer time: the environment may have
403422 // reconnected on a new base URL since this cycle started.
404423 resolveUploadUrl: (relativeUrl) => {
424 requireUploadAccess();
405425 const currentConnection = appAtomRegistry.get(
406426 environmentSession.preparedConnectionValueAtom(environmentId),
407427 );
......@@ -411,6 +431,7 @@ export async function prepareTurnAttachments(input: {
411431 },
412432 transport: (url) => ({
413433 done: uploadFileBytes(
434 environmentId,
414435 attachment,
415436 url,
416437 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 @@
11import { 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
311import { 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 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts";
33import { Atom } from "effect/reactivity";
44import { useEffect, useRef } from "react";
55
......@@ -24,6 +24,7 @@ import {
2424 setComposerDraftAttachmentUpload,
2525} from "./use-composer-drafts";
2626import { useRemoteConnectionStatus } from "./use-remote-environment-registry";
27import { readEnvironmentScope, useEnvironmentsWithScope } from "./session";
2728
2829export {
2930 composerAttachmentUploadBlockReason,
......@@ -48,6 +49,7 @@ export function useComposerAttachmentUploadState(
4849}
4950
5051export function retryComposerAttachmentUpload(environmentId: EnvironmentId, attachmentId: string) {
52 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
5153 uploadQueue?.retry(environmentId, attachmentId);
5254}
5355
......@@ -57,6 +59,10 @@ export function useComposerAttachmentUploadWorker() {
5759 const queuedMessages = useThreadOutboxMessages();
5860 const serverConfigs = useServerConfigs();
5961 const { connectedEnvironments } = useRemoteConnectionStatus();
62 const operableEnvironments = useEnvironmentsWithScope(
63 connectedEnvironments,
64 AuthOrchestrationOperateScope,
65 );
6066 const queueRef = useRef<ReturnType<typeof createComposerAttachmentUploadQueue> | null>(null);
6167
6268 useEffect(() => {
......@@ -117,7 +123,12 @@ export function useComposerAttachmentUploadWorker() {
117123 );
118124 const requests = Object.entries(drafts).flatMap(([key, draft]) => {
119125 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 [];
121132 return draft.attachments
122133 .filter((attachment) =>
123134 canUploadComposerAttachment(attachment, serverConfigs.get(environmentId)),
......@@ -125,5 +136,5 @@ export function useComposerAttachmentUploadWorker() {
125136 .map((attachment) => ({ environmentId, attachment }));
126137 });
127138 queueRef.current?.sync(requests);
128 }, [connectedEnvironments, drafts, queuedMessages, serverConfigs]);
139 }, [connectedEnvironments, drafts, operableEnvironments, queuedMessages, serverConfigs]);
129140}
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 @@
11import { useAtomValue } from "@effect/atom-react";
22import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
3import type { EnvironmentId } from "@t3tools/contracts";
3import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
44import * as Option from "effect/Option";
5import { Atom } from "effect/reactivity";
5import { AsyncResult, Atom } from "effect/reactivity";
6import { useMemo } from "react";
67
78import { connectionAtomRuntime } from "../connection/runtime";
9import { appAtomRegistry } from "./atom-registry";
810
911export 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
1169const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
1270 Atom.withLabel("mobile-prepared-connection:empty"),
1371);
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", () => ({
4747 ],
4848 ]),
4949}));
50vi.mock("./session", () => ({ readEnvironmentScope: () => true }));
5051vi.mock("./threads", () => ({ threadEnvironment: {} }));
5152vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
5253vi.mock("./use-thread-selection", () => ({
apps/mobile/src/state/use-selected-thread-requests.ts+13-3
......@@ -19,6 +19,7 @@ import {
1919 type PendingThreadRequests,
2020 type ThreadUserInputQuestion,
2121} from "@t3tools/client-runtime/state/thread-requests";
22import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2223import { Atom } from "effect/reactivity";
2324
2425import { threadEnvironment } from "../state/threads";
......@@ -33,6 +34,7 @@ import { appAtomRegistry } from "./atom-registry";
3334import { useSelectedThreadPendingRequests } from "./use-thread-detail";
3435import { useThreadSelection } from "./use-thread-selection";
3536import { useAtomCommand } from "./use-atom-command";
37import { readEnvironmentScope } from "./session";
3638
3739const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] };
3840
......@@ -211,7 +213,10 @@ export function useSelectedThreadRequests() {
211213
212214 const onRespondToApproval = useCallback(
213215 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
214 if (!selectedThreadShell) {
216 if (
217 !selectedThreadShell ||
218 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
219 ) {
215220 return;
216221 }
217222 if (
......@@ -241,7 +246,8 @@ export function useSelectedThreadRequests() {
241246 !selectedThreadShell ||
242247 !activePendingUserInput ||
243248 activePendingUserInput.responseCapability === "not_resumable" ||
244 !activePendingUserInputAnswers
249 !activePendingUserInputAnswers ||
250 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
245251 ) {
246252 return;
247253 }
......@@ -318,7 +324,11 @@ export function useSelectedThreadRequests() {
318324
319325 // Closes an async question without messaging the agent.
320326 const onDismissUserInput = useCallback(async () => {
321 if (!selectedThreadShell || !activePendingUserInput) {
327 if (
328 !selectedThreadShell ||
329 !activePendingUserInput ||
330 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
331 ) {
322332 return;
323333 }
324334
apps/mobile/src/state/use-thread-composer-state.ts+10
......@@ -1,3 +1,5 @@
1import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "./session";
13import type { ComposerTextPaste } from "../native/T3ComposerEditor.types";
24import { useAtomValue } from "@effect/atom-react";
35import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
......@@ -548,6 +550,12 @@ export function useThreadComposerState() {
548550
549551 const onSendMessage = useCallback(
550552 async (followUpOverride?: ActiveTurnComposerAction) => {
553 if (
554 selectedThreadShell &&
555 selectedEnvironmentRuntime?.connectionState === "connected" &&
556 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
557 )
558 return null;
551559 if (!selectedThreadShell) {
552560 return null;
553561 }
......@@ -624,6 +632,8 @@ export function useThreadComposerState() {
624632 ? parseCodexFeedbackCommand(text)
625633 : null;
626634 if (feedbackCommand) {
635 if (!readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope))
636 return null;
627637 if (thread.activeProviderThreadId === null) {
628638 Alert.alert("Start a Codex thread first", "Send a message before you submit feedback.");
629639 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"
1313import type { PreparedTurnAttachments } from "../lib/attachmentUpload";
1414
1515const harness = vi.hoisted(() => ({
16 canOperate: true,
1617 manager: null as unknown as ReturnType<
1718 typeof import("./thread-outbox-manager").createThreadOutboxManager
1819 >,
......@@ -95,6 +96,11 @@ vi.mock("./use-atom-command", () => ({
9596 useAtomCommand: () => async () => undefined,
9697}));
9798
99vi.mock("./session", () => ({
100 readEnvironmentScope: () => harness.canOperate,
101 useEnvironmentsWithScope: () => new Set(),
102}));
103
98104vi.mock("./use-thread-outbox", async () => {
99105 const { Atom } = await import("effect/reactivity");
100106 return {
......@@ -203,6 +209,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {
203209
204210beforeEach(() => {
205211 appAtomRegistry.set(acknowledgedThreadMessagesAtom, []);
212 harness.canOperate = true;
206213 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });
207214});
208215
......@@ -220,6 +227,27 @@ afterEach(() => {
220227});
221228
222229describe("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
223251 it("abandons reused uploads when an editor saves changed text during verification", async () => {
224252 const message = withReusedFileUpload(
225253 queuedMessage({
apps/mobile/src/state/use-thread-outbox-drain.ts+28
......@@ -7,6 +7,7 @@ import {
77import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
88import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
99import {
10 AuthOrchestrationOperateScope,
1011 CommandId,
1112 DEFAULT_PROVIDER_INTERACTION_MODE,
1213 DEFAULT_RUNTIME_MODE,
......@@ -39,6 +40,7 @@ import {
3940 recordPendingThreadCreationOutcome,
4041} from "./pending-thread-creation";
4142import { serverEnvironment } from "./server";
43import { readEnvironmentScope, useEnvironmentsWithScope } from "./session";
4244import {
4345 confirmThreadOutboxMessageQueued,
4446 threadOutboxManager,
......@@ -226,6 +228,9 @@ export async function prepareQueuedMessageAttachments(
226228 if (!(await confirmThreadOutboxMessageQueued(queuedMessage))) {
227229 return { status: "abandoned" };
228230 }
231 if (!readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope)) {
232 return { status: "abandoned" };
233 }
229234 const revision = threadOutboxRevision(queuedMessage.messageId);
230235 if (!isQueuedMessagePayloadCurrent(queuedMessage, revision)) {
231236 return { status: "abandoned" };
......@@ -668,6 +673,10 @@ export function useThreadOutboxDrain(): void {
668673 const projects = useProjects();
669674 const serverConfigs = useServerConfigs();
670675 const { connectedEnvironments } = useRemoteConnectionStatus();
676 const operableEnvironments = useEnvironmentsWithScope(
677 connectedEnvironments,
678 AuthOrchestrationOperateScope,
679 );
671680 const [retryTick, setRetryTick] = useState(0);
672681 const retryAttemptRef = useRef(new Map<MessageId, number>());
673682 const retryNotBeforeRef = useRef(new Map<MessageId, number>());
......@@ -794,6 +803,9 @@ export function useThreadOutboxDrain(): void {
794803
795804 const sendQueuedMessage = useCallback(
796805 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
806 const hasAccess = () =>
807 readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope);
808 if (!hasAccess()) return true;
797809 const serverConfig = appAtomRegistry.get(
798810 serverEnvironment.configValueAtom(queuedMessage.environmentId),
799811 );
......@@ -808,6 +820,7 @@ export function useThreadOutboxDrain(): void {
808820 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
809821
810822 if (settings.runtimeMode !== thread.runtimeMode) {
823 if (!hasAccess()) return true;
811824 const runtimeResult = await setThreadRuntimeMode({
812825 environmentId: queuedMessage.environmentId,
813826 input: {
......@@ -824,6 +837,7 @@ export function useThreadOutboxDrain(): void {
824837 }
825838
826839 if (settings.interactionMode !== thread.interactionMode) {
840 if (!hasAccess()) return true;
827841 const interactionResult = await setThreadInteractionMode({
828842 environmentId: queuedMessage.environmentId,
829843 input: {
......@@ -842,6 +856,7 @@ export function useThreadOutboxDrain(): void {
842856 let prepared: PreparedTurnAttachments;
843857 let persistedMessage: QueuedThreadMessage;
844858 let deliveryRevision: number;
859 if (!hasAccess()) return true;
845860 try {
846861 const preparedResult = await prepareQueuedMessageAttachments(
847862 queuedMessage,
......@@ -861,6 +876,7 @@ export function useThreadOutboxDrain(): void {
861876 return true;
862877 }
863878 } catch (error) {
879 if (!hasAccess()) return true;
864880 logThreadOutboxUploadFailure(queuedMessage, error);
865881 if (!shouldRetryThreadOutboxDelivery(error)) {
866882 return restoreQueuedMessage(
......@@ -888,6 +904,7 @@ export function useThreadOutboxDrain(): void {
888904 settings,
889905 currentConfig.providers,
890906 );
907 if (!hasAccess()) return true;
891908 const deliveryResult = await startTurn({
892909 environmentId: queuedMessage.environmentId,
893910 input: {
......@@ -922,6 +939,7 @@ export function useThreadOutboxDrain(): void {
922939 dispatchMode: queuedMessage.dispatchMode ?? "start",
923940 },
924941 });
942 if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
925943 const failure = reportFailure(deliveryResult, "start-turn");
926944 if (failure?.action === "retry") {
927945 return false;
......@@ -952,6 +970,9 @@ export function useThreadOutboxDrain(): void {
952970 creation: QueuedThreadCreation,
953971 projectCwd: string,
954972 ) => {
973 const hasAccess = () =>
974 readEnvironmentScope(queuedMessage.environmentId, AuthOrchestrationOperateScope);
975 if (!hasAccess()) return true;
955976 const modelSelection = queuedMessage.modelSelection;
956977 if (modelSelection === undefined) {
957978 return false;
......@@ -997,6 +1018,7 @@ export function useThreadOutboxDrain(): void {
9971018 return true;
9981019 }
9991020 } catch (error) {
1021 if (!hasAccess()) return true;
10001022 logThreadOutboxUploadFailure(queuedMessage, error);
10011023 if (!shouldRetryThreadOutboxDelivery(error)) {
10021024 return restoreQueuedMessage(
......@@ -1024,6 +1046,7 @@ export function useThreadOutboxDrain(): void {
10241046 settings,
10251047 currentConfig.providers,
10261048 );
1049 if (!hasAccess()) return true;
10271050 const deliveryResult = await startTurn({
10281051 environmentId: queuedMessage.environmentId,
10291052 input: buildProjectThreadStartTurnInput({
......@@ -1054,6 +1077,7 @@ export function useThreadOutboxDrain(): void {
10541077 }),
10551078 });
10561079 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
1080 if (AsyncResult.isFailure(deliveryResult) && !hasAccess()) return true;
10571081 const failure = reportFailure(deliveryResult, "start-turn");
10581082 if (failure?.action === "retry") {
10591083 return false;
......@@ -1188,6 +1212,9 @@ export function useThreadOutboxDrain(): void {
11881212 environmentConnected: environment?.connectionState === "connected",
11891213 threadBusy: threadRuntimeIsActive(thread?.runtime),
11901214 });
1215 if (deliveryAction === "send" && !operableEnvironments.has(nextQueuedMessage.environmentId)) {
1216 continue;
1217 }
11911218 // The delivery action resolves first; capability checks apply only to
11921219 // a message that will send. Checking earlier would restore a
11931220 // creation whose startTurn already made the thread as a duplicate draft
......@@ -1345,6 +1372,7 @@ export function useThreadOutboxDrain(): void {
13451372 connectedEnvironments,
13461373 dispatchingQueuedMessageId,
13471374 editingQueuedMessageIds,
1375 operableEnvironments,
13481376 projects,
13491377 queuedMessagesByThreadKey,
13501378 retryTick,
apps/server/src/auth/EnvironmentAuth.test.ts+3-18
......@@ -1,5 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { AuthAdministrativeScopes } from "@t3tools/contracts";
2import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
33import { expect, it } from "@effect/vitest";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
......@@ -328,13 +328,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
328328 );
329329
330330 expect(verified.sessionId.length).toBeGreaterThan(0);
331 expect(verified.scopes).toEqual([
332 "orchestration:read",
333 "orchestration:operate",
334 "terminal:operate",
335 "review:write",
336 "relay:read",
337 ]);
331 expect(verified.scopes).toEqual(AuthStandardClientScopes);
338332 expect(verified.subject).toBe("one-time-token");
339333 }).pipe(Effect.provide(layerEnvironmentAuth())),
340334 );
......@@ -535,16 +529,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
535529 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
536530 );
537531
538 expect(verified.scopes).toEqual([
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 ]);
532 expect(verified.scopes).toEqual(AuthAdministrativeScopes);
548533 expect(verified.subject).toBe("administrative-bootstrap");
549534 }).pipe(Effect.provide(layerEnvironmentAuth())),
550535 );
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+3-20
......@@ -1,3 +1,4 @@
1import { AuthAdministrativeScopes } from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { expect, it } from "@effect/vitest";
34import * as Effect from "effect/Effect";
......@@ -79,29 +80,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) =
7980 const listedAfterRevoke = yield* environmentAuth.listSessions();
8081
8182 expect(issued.method).toBe("bearer-access-token");
82 expect(issued.scopes).toEqual([
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 ]);
83 expect(issued.scopes).toEqual(AuthAdministrativeScopes);
9284 expect(issued.client.deviceType).toBe("bot");
9385 expect(issued.client.label).toBe("deploy-bot");
9486 expect(verified.sessionId).toBe(issued.sessionId);
95 expect(verified.scopes).toEqual([
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 ]);
87 expect(verified.scopes).toEqual(AuthAdministrativeScopes);
10588 expect(verified.method).toBe("bearer-access-token");
10689 expect(listedBeforeRevoke).toHaveLength(1);
10790 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) => {
3434
3535 expect(descriptor.policy).toBe("desktop-managed-local");
3636 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
37 expect(descriptor.serverUpdateScope).toBe("environment:maintain");
3738 // Packaged desktop has no devUrl, but still needs the port scope: it
3839 // scans upward from 3773 for a free port and binds 127.0.0.1, so a second
3940 // instance shares this one's hostname on a different port.
......@@ -88,6 +89,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
8889
8990 expect(descriptor.policy).toBe("loopback-browser");
9091 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
92 expect(descriptor.serverUpdateScope).toBe("environment:maintain");
9193 expect(descriptor.sessionCookieName).toMatch(/^t3_session_3773_[a-f0-9]{12}$/);
9294 }).pipe(
9395 Effect.provide(
apps/server/src/auth/EnvironmentAuthPolicy.ts+2-1
......@@ -1,4 +1,4 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
1import { AuthEnvironmentMaintainScope, type ServerAuthDescriptor } from "@t3tools/contracts";
22import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
......@@ -40,6 +40,7 @@ export const make = Effect.gen(function* () {
4040 policy,
4141 bootstrapMethods,
4242 sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
43 serverUpdateScope: AuthEnvironmentMaintainScope,
4344 sessionCookieName: resolveSessionCookieName({
4445 mode: config.mode,
4546 port: config.port,
apps/server/src/auth/McpOAuth.test.ts+2-2
......@@ -1,7 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import {
33 AuthAdministrativeScopes,
4 type AuthEnvironmentScope,
4 type AuthCreatePairingCredentialInput,
55 EnvironmentHttpApi,
66} from "@t3tools/contracts";
77import { expect, it } from "@effect/vitest";
......@@ -418,7 +418,7 @@ it.live("one-click approves only access the browser session holds the scopes for
418418 const clientId = yield* registeredClientId(handler);
419419 const params = authorizeParams(clientId);
420420 // 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"]>) =>
422422 Effect.gen(function* () {
423423 const pairing = yield* auth.issuePairingCredential({ scopes });
424424 const response = yield* handler(
apps/server/src/auth/PairingGrantStore.test.ts+3-17
......@@ -1,3 +1,4 @@
1import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
23import { expect, it } from "@effect/vitest";
34import * as Duration from "effect/Duration";
......@@ -83,13 +84,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
8384 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
8485
8586 expect(first.method).toBe("one-time-token");
86 expect(first.scopes).toEqual([
87 "orchestration:read",
88 "orchestration:operate",
89 "terminal:operate",
90 "review:write",
91 "relay:read",
92 ]);
87 expect(first.scopes).toEqual(AuthStandardClientScopes);
9388 expect(first.subject).toBe("one-time-token");
9489 expect(first.label).toBe("Julius iPhone");
9590 expect(issued.label).toBe("Julius iPhone");
......@@ -168,16 +163,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
168163 const third = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
169164
170165 expect(first.method).toBe("desktop-bootstrap");
171 expect(first.scopes).toEqual([
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 ]);
166 expect(first.scopes).toEqual(AuthAdministrativeScopes);
181167 expect(first.subject).toBe("desktop-bootstrap");
182168 expect(second.method).toBe("desktop-bootstrap");
183169 expect(third.method).toBe("desktop-bootstrap");
apps/server/src/auth/RpcAuthorization.test.ts+80-6
......@@ -1,4 +1,9 @@
11import {
2 AuthEnvironmentMaintainScope,
3 AuthProvidersManageScope,
4 AuthSettingsWriteScope,
5 DEFAULT_SERVER_SETTINGS,
6 ProviderInstanceId,
27 AuthOrchestrationOperateScope,
38 AuthOrchestrationReadScope,
49 AuthRelayReadScope,
......@@ -28,7 +33,7 @@ describe("RPC authorization scopes", () => {
2833 AuthOrchestrationReadScope,
2934 );
3035 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportHostPowerState)).toBe(
31 AuthOrchestrationOperateScope,
36 AuthEnvironmentMaintainScope,
3237 );
3338 expect(requiredScopeForRpcMethod(WS_METHODS.serverGetBackgroundPolicy)).toBe(
3439 AuthOrchestrationReadScope,
......@@ -75,13 +80,13 @@ describe("RPC authorization scopes", () => {
7580 AuthOrchestrationReadScope,
7681 );
7782 expect(requiredScopeForRpcMethod(WS_METHODS.serverPrepareAcpRegistryAgent)).toBe(
78 AuthOrchestrationOperateScope,
83 AuthProvidersManageScope,
7984 );
8085 expect(requiredScopeForRpcMethod(WS_METHODS.serverUninstallAcpRegistryManagedBinary)).toBe(
81 AuthOrchestrationOperateScope,
86 AuthProvidersManageScope,
8287 );
8388 expect(requiredScopeForRpcMethod(WS_METHODS.serverAcceptAcpRegistryUrlAuth)).toBe(
84 AuthOrchestrationOperateScope,
89 AuthProvidersManageScope,
8590 );
8691 expect(requiredScopeForRpcMethod(WS_METHODS.serverListAcpRegistrySessions)).toBe(
8792 AuthOrchestrationReadScope,
......@@ -90,7 +95,7 @@ describe("RPC authorization scopes", () => {
9095 AuthOrchestrationOperateScope,
9196 );
9297 expect(requiredScopeForRpcMethod(WS_METHODS.serverLogoutAcpRegistry)).toBe(
93 AuthOrchestrationOperateScope,
98 AuthProvidersManageScope,
9499 );
95100 });
96101
......@@ -160,9 +165,78 @@ describe("RPC scope middleware", () => {
160165 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
161166 ).toMatchObject({
162167 _tag: "EnvironmentAuthorizationError",
163 requiredScope: AuthOrchestrationOperateScope,
168 requiredScope: AuthEnvironmentMaintainScope,
164169 });
165170 expect(handled).toEqual([]);
166171 }).pipe(Effect.scoped),
167172 );
168173});
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 @@
11import {
2 CLIENT_GUARDED_RPC_SCOPES,
23 type DeviceListInput,
34 AuthAccessReadScope,
5 ServerSettingsPatch,
6 ProviderInstanceMutation,
7 requiredScopesForServerSettingsPatch,
8 AuthSettingsWriteScope,
9 AuthProvidersManageScope,
10 AuthEnvironmentMaintainScope,
411 AuthOrchestrationOperateScope,
512 AuthOrchestrationReadScope,
613 AuthRelayReadScope,
......@@ -15,6 +22,7 @@ import {
1522 WsRpcGroup,
1623} from "@t3tools/contracts";
1724import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
1826import * as Layer from "effect/Layer";
1927import type * as RpcGroup from "effect/rpc/RpcGroup";
2028
......@@ -26,6 +34,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
2634 * runtime failure.
2735 */
2836export const RPC_REQUIRED_SCOPES = {
37 ...CLIENT_GUARDED_RPC_SCOPES,
2938 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
3039 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
3140 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
......@@ -41,61 +50,56 @@ export const RPC_REQUIRED_SCOPES = {
4150 [WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
4251 [WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
4352 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
44 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
45 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
46 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
47 [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
48 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
49 [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope,
50 [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope,
51 [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope,
52 [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope,
53 [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,
54 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
55 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
56 [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope,
57 [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope,
58 [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope,
53 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope,
54 [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope,
55 [WS_METHODS.providerAuthStart]: AuthProvidersManageScope,
56 [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope,
57 [WS_METHODS.providerAuthComplete]: AuthProvidersManageScope,
58 [WS_METHODS.chatGptReconnectProfile]: AuthProvidersManageScope,
59 [WS_METHODS.chatGptImportProfile]: AuthProvidersManageScope,
60 [WS_METHODS.chatGptHandoffSubscribe]: AuthProvidersManageScope,
61 [WS_METHODS.codexAuthCallbackSubscribe]: AuthProvidersManageScope,
62 [WS_METHODS.providerAuthRespond]: AuthProvidersManageScope,
63 [WS_METHODS.providerAuthCancel]: AuthProvidersManageScope,
64 [WS_METHODS.providerAuthLogout]: AuthProvidersManageScope,
65 [WS_METHODS.providerAuthSubscribe]: AuthProvidersManageScope,
66 [WS_METHODS.providerInstallStart]: AuthProvidersManageScope,
67 [WS_METHODS.providerInstallCancel]: AuthProvidersManageScope,
5968 [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope,
60 [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope,
61 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
62 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
63 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
64 [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope,
65 [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
69 [WS_METHODS.providerInstallRemove]: AuthProvidersManageScope,
70 [WS_METHODS.serverUpdateServer]: AuthEnvironmentMaintainScope,
71 [WS_METHODS.serverUpdateServerWithProgress]: AuthEnvironmentMaintainScope,
72 [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope,
73 [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope,
74 [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
6675 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
67 [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
76 [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
6877 [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
69 [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthOrchestrationOperateScope,
70 [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthOrchestrationOperateScope,
71 [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthOrchestrationOperateScope,
78 [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope,
79 [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: AuthProvidersManageScope,
80 [WS_METHODS.serverAcceptAcpRegistryUrlAuth]: AuthProvidersManageScope,
7281 [WS_METHODS.serverListAcpRegistrySessions]: AuthOrchestrationReadScope,
7382 [WS_METHODS.serverImportAcpRegistrySession]: AuthOrchestrationOperateScope,
7483 [WS_METHODS.serverDeleteAcpRegistrySession]: AuthOrchestrationOperateScope,
7584 [WS_METHODS.serverListAcpRegistryProviders]: AuthOrchestrationReadScope,
76 [WS_METHODS.serverSetAcpRegistryProvider]: AuthOrchestrationOperateScope,
77 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthOrchestrationOperateScope,
78 [WS_METHODS.serverLogoutAcpRegistry]: AuthOrchestrationOperateScope,
85 [WS_METHODS.serverSetAcpRegistryProvider]: AuthProvidersManageScope,
86 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
87 [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
7988 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
8089 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
8190 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
8291 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
8392 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
8493 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
85 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
94 [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope,
8695 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
8796 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
88 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
97 [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
8998 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
90 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
99 [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,
91100 [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope,
92101 [WS_METHODS.scheduledTasksList]: AuthOrchestrationReadScope,
93102 [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,
99103 [WS_METHODS.secretsAnswerRequest]: AuthOrchestrationOperateScope,
100104 // Delivery logs hold request bodies, so they need the same scope as the URL.
101105 [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope,
......@@ -195,8 +199,8 @@ export const RPC_REQUIRED_SCOPES = {
195199 [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope,
196200 [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
197201 [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
198 [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
199 [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
202 [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope,
203 [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope,
200204 [WS_METHODS.deviceList]: AuthOrchestrationReadScope,
201205 [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
202206 [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
......@@ -227,13 +231,30 @@ export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) =>
227231 requiredScope,
228232 });
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
230249/** Authorizes every RPC on one connection against that connection's session scopes. */
231250export const layer = (scopes: ReadonlyArray<AuthEnvironmentScope>) =>
232 Layer.succeed(RpcScopeAuthorization)((effect, { rpc }) => {
233 const requiredScope = requiredScopeForRpcMethod(rpc._tag);
234 return scopes.includes(requiredScope)
235 ? effect
236 : Effect.fail(rpcAuthorizationError(requiredScope));
251 Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => {
252 const requiredScopes =
253 rpc._tag === WS_METHODS.serverUpdateSettings
254 ? requiredScopesForSettingsUpdate(payload)
255 : [requiredScopeForRpcMethod(rpc._tag)];
256 const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope));
257 return requiredScope === undefined ? effect : Effect.fail(rpcAuthorizationError(requiredScope));
237258 });
238259
239260/** 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 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentId } from "@t3tools/contracts";
2import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts";
33import { expect, it } from "@effect/vitest";
44import * as Duration from "effect/Duration";
55import * as Effect from "effect/Effect";
......@@ -346,13 +346,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
346346
347347 expect(verified.method).toBe("bearer-access-token");
348348 expect(verified.subject).toBe("test-clock");
349 expect(verified.scopes).toEqual([
350 "orchestration:read",
351 "orchestration:operate",
352 "terminal:operate",
353 "review:write",
354 "relay:read",
355 ]);
349 expect(verified.scopes).toEqual(AuthStandardClientScopes);
356350 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
357351 );
358352
apps/server/src/auth/http.ts+6
......@@ -2,6 +2,9 @@ import {
22 AuthAccessReadScope,
33 AuthAccessWriteScope,
44 AuthStandardClientScopes,
5 AuthSettingsWriteScope,
6 AuthProvidersManageScope,
7 AuthEnvironmentMaintainScope,
58 AuthOrchestrationOperateScope,
69 AuthOrchestrationReadScope,
710 AuthRelayReadScope,
......@@ -366,6 +369,9 @@ export const layer = HttpApiBuilder.group(
366369 allowedScopes: new Set<AuthEnvironmentScope>([
367370 AuthOrchestrationReadScope,
368371 AuthOrchestrationOperateScope,
372 AuthSettingsWriteScope,
373 AuthProvidersManageScope,
374 AuthEnvironmentMaintainScope,
369375 AuthTerminalOperateScope,
370376 AuthReviewWriteScope,
371377 AuthAccessReadScope,
apps/server/src/observability/RpcInstrumentation.test.ts+2-2
......@@ -1,6 +1,6 @@
11import { assert, describe, it } from "@effect/vitest";
22import {
3 AuthOrchestrationOperateScope,
3 AuthEnvironmentMaintainScope,
44 AuthOrchestrationReadScope,
55 type AuthEnvironmentScope,
66 ScheduledTaskError,
......@@ -319,7 +319,7 @@ describe("WS RPC instrumentation middleware", () => {
319319 message: Option.none(),
320320 }).pipe(Effect.withSpan("signalProcess.child")),
321321 ),
322 connectionMiddleware([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]),
322 connectionMiddleware([AuthOrchestrationReadScope, AuthEnvironmentMaintainScope]),
323323 ),
324324 ),
325325 );
apps/server/src/provider/ProviderRegistry.test.ts+93-1
......@@ -2,7 +2,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
22import * as CodexInstallation from "./CodexInstallation.ts";
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
44import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, it, assert } from "@effect/vitest";
5import { describe, it, assert, expect } from "@effect/vitest";
66import * as DateTime from "effect/DateTime";
77import * as Deferred from "effect/Deferred";
88import * as Effect from "effect/Effect";
......@@ -2077,6 +2077,98 @@ it.layer(
20772077 ]);
20782078 });
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
20802172 it.effect("persists the merged snapshot when a live update has empty models", () =>
20812173 Effect.gen(function* () {
20822174 const cursorDriver = ProviderDriverKind.make("cursor");
apps/server/src/provider/ProviderRegistry.ts+45-5
......@@ -36,10 +36,12 @@ import {
3636} from "@t3tools/contracts";
3737import * as Cause from "effect/Cause";
3838import * as Context from "effect/Context";
39import * as Deferred from "effect/Deferred";
3940import * as Effect from "effect/Effect";
4041import * as Equal from "effect/Equal";
4142import * as FileSystem from "effect/FileSystem";
4243import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
4345import * as Path from "effect/Path";
4446import * as PubSub from "effect/PubSub";
4547import * as Ref from "effect/Ref";
......@@ -701,12 +703,50 @@ export const layer = Layer.effect(
701703 );
702704 });
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.
704714 const refreshAll = Effect.fn("refreshAll")(function* () {
705 const sources = yield* getLiveSources;
706 return yield* Effect.forEach(sources, (source) => refreshOneSource(source), {
707 concurrency: "unbounded",
708 discard: true,
709 }).pipe(Effect.andThen(Ref.get(providersRef)));
715 const claimed = yield* Ref.modify(
716 refreshAllInFlightRef,
717 (
718 inFlight,
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);
710750 });
711751
712752 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) => {
686686 }).pipe(Effect.provide(layerServerSettings())),
687687 );
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
689774 it.effect("preserves enabled text generation selections for non-built-in drivers", () =>
690775 Effect.gen(function* () {
691776 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/web/src/cloud/primaryCloudLinkState.ts+20-4
......@@ -1,5 +1,9 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts";
2import {
3 AuthRelayReadScope,
4 EnvironmentId,
5 type EnvironmentCloudLinkStateResult,
6} from "@t3tools/contracts";
37import * as Cause from "effect/Cause";
48import * as Context from "effect/Context";
59import * as Effect from "effect/Effect";
......@@ -12,6 +16,7 @@ import { useCallback, useMemo } from "react";
1216import { usePrimaryEnvironment } from "../state/environments";
1317import { runtime } from "../lib/runtime";
1418import { appAtomRegistry } from "../rpc/atomRegistry";
19import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
1520import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment";
1621import { hasCloudPublicConfig } from "./publicConfig";
1722
......@@ -44,13 +49,24 @@ function targetKey(target: CloudLinkTarget): string {
4449}
4550
4651function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void {
47 if (target) {
52 if (
53 target &&
54 readEnvironmentScope(EnvironmentId.make(target.environmentId), AuthRelayReadScope)
55 ) {
4856 appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target)));
4957 }
5058}
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
5267export function usePrimaryCloudLinkState() {
5368 const primary = usePrimaryEnvironment();
69 const canReadRelay = useEnvironmentScope(primary?.environmentId ?? null, AuthRelayReadScope);
5470 const target = useMemo(
5571 () =>
5672 primary?.entry.target._tag === "PrimaryConnectionTarget"
......@@ -65,7 +81,7 @@ export function usePrimaryCloudLinkState() {
6581 );
6682 // Builds without T3 Connect have no link to read; skip the request.
6783 const atom =
68 target && hasCloudPublicConfig()
84 target && hasCloudPublicConfig() && canReadRelay
6985 ? primaryCloudLinkStateAtom(targetKey(target))
7086 : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM;
7187 const result = useAtomValue(atom);
......@@ -79,7 +95,7 @@ export function usePrimaryCloudLinkState() {
7995 }
8096
8197 return {
82 data: Option.getOrNull(AsyncResult.value(result)),
98 data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)),
8399 error,
84100 isPending: result.waiting,
85101 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 @@
11import { useAuth } from "@clerk/react";
22import { findErrorTraceId } from "@t3tools/client-runtime/errors";
3import { EnvironmentId, AuthRelayReadScope, AuthRelayWriteScope } from "@t3tools/contracts";
34import {
45 isAtomCommandInterrupted,
56 settlePromise,
......@@ -9,13 +10,14 @@ import { useState } from "react";
910
1011import { toastManager } from "../components/ui/toast";
1112import { relayEnvironmentDiscovery } from "../state/relay";
13import { readEnvironmentScope } from "../state/session";
1214import { useAtomCommand } from "../state/use-atom-command";
1315import {
1416 linkPrimaryEnvironment as linkPrimaryEnvironmentAtom,
1517 unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom,
1618 updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom,
1719} from "./linkEnvironmentAtoms";
18import { usePrimaryCloudLinkState } from "./primaryCloudLinkState";
20import { readCachedPrimaryCloudLinkState, usePrimaryCloudLinkState } from "./primaryCloudLinkState";
1921import { resolveRelayClerkTokenOptions } from "./publicConfig";
2022
2123export interface CloudLinkDesiredState {
......@@ -87,8 +89,31 @@ export function useCloudLinkController() {
8789 reportUpdateFailure(new Error("Local environment is not ready yet."));
8890 return false;
8991 }
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;
91113 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
93118 // A failure after this point may follow a partially applied mutation (e.g.
94119 // the link succeeded but the preference update did not), so every exit —
......@@ -109,6 +134,8 @@ export function useCloudLinkController() {
109134 return false;
110135 }
111136 } else {
137 const currentLinkState = readLinkState();
138 if (currentLinkState === null) return false;
112139 if (tokenResult._tag === "Failure") {
113140 reportUpdateFailure(squashAtomCommandFailure(tokenResult));
114141 return false;
......@@ -118,7 +145,8 @@ export function useCloudLinkController() {
118145 reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this."));
119146 return false;
120147 }
121 if (!linked || managedTunnelActive !== desired.managedTunnel) {
148 const currentManagedTunnel = currentLinkState.managedTunnelActive ?? currentLinkState.linked;
149 if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) {
122150 const linkResult = await linkPrimaryEnvironment({
123151 target,
124152 clerkToken,
......@@ -132,6 +160,10 @@ export function useCloudLinkController() {
132160 return false;
133161 }
134162 }
163 if (!canManageLink() || readLinkState() === null) {
164 primaryCloudLinkState.refresh();
165 return false;
166 }
135167 const prefResult = await updatePrimaryEnvironmentPreferences({
136168 target,
137169 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 @@
11// @vitest-environment jsdom
22
3import { EnvironmentId } from "@t3tools/contracts";
3import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
44import { act, type ComponentProps, type ReactNode } from "react";
55import { renderToStaticMarkup } from "react-dom/server";
66import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -38,10 +38,19 @@ vi.mock("./ui/tooltip", async () => {
3838});
3939vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
4040vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
41vi.mock("../state/session", async (importOriginal) => ({
42 ...(await importOriginal<typeof import("../state/session")>()),
43 usePreparedConnection: () => ({ _tag: "Loading" }),
44}));
41vi.mock("../state/session", async (importOriginal) => {
42 const actual = await importOriginal<typeof import("../state/session")>();
43 const { AuthStandardClientScopes } = await import("@t3tools/contracts");
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});
4554vi.mock("../state/entities", () => ({
4655 readThreadShell: () => null,
4756 useProjects: () => [],
apps/web/src/components/ChatMarkdown.tsx+20-8
......@@ -1,4 +1,5 @@
11import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
23import { useAtomValue } from "@effect/atom-react";
34import {
45 COMPOSER_CONTEXT_CLIPBOARD_MIME,
......@@ -171,7 +172,7 @@ import { readThreadShell, useProjects } from "../state/entities";
171172import { serverEnvironment } from "../state/server";
172173import { shellEnvironment } from "../state/shell";
173174import { assetEnvironment } from "../state/assets";
174import { usePreparedConnection } from "../state/session";
175import { readEnvironmentScope, usePreparedConnection, useEnvironmentScope } from "../state/session";
175176import { previewEnvironment } from "../state/preview";
176177import { useAtomCommand } from "../state/use-atom-command";
177178import { useAtomQueryRunner } from "../state/use-atom-query-runner";
......@@ -2516,12 +2517,11 @@ function useChatMarkdownState({
25162517 });
25172518 const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId);
25182519 const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
2520 const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
25192521 const remoteOpen = useRemoteOpenResolution(environmentId);
2520 const canUseShellActions = canUseMarkdownFileShellActions(
2521 environmentId,
2522 remoteOpen.state.mode,
2523 remoteOpen.isResolved,
2524 );
2522 const canUseShellActions =
2523 canOperateHost &&
2524 canUseMarkdownFileShellActions(environmentId, remoteOpen.state.mode, remoteOpen.isResolved);
25252525 const preparedConnection = usePreparedConnection(environmentId);
25262526 const openMarkdownMedia = useCallback(
25272527 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {
......@@ -2591,6 +2591,13 @@ function useChatMarkdownState({
25912591 ),
25922592 );
25932593 }
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 }
25942601 return openInEditor({
25952602 environmentId,
25962603 input: { cwd: filePath, editor: "file-manager", reveal: true },
......@@ -2687,6 +2694,7 @@ function useChatMarkdownState({
26872694 const updateThreadPullRequestLink = useCallback(
26882695 async (href: string, linked: boolean) => {
26892696 if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return;
2697 if (!readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope)) return;
26902698 await pullRequestLinking.changeLink(threadRef, href, linked);
26912699 },
26922700 [linkedThreadPullRequestFor, pullRequestLinking, threadRef],
......@@ -2878,6 +2886,7 @@ function useChatMarkdownState({
28782886
28792887 const componentState = useMemo(
28802888 () => ({
2889 canOperateHost,
28812890 cwd,
28822891 diffThemeName,
28832892 environmentId,
......@@ -2909,6 +2918,7 @@ function useChatMarkdownState({
29092918 updateThreadPullRequestLink,
29102919 }),
29112920 [
2921 canOperateHost,
29122922 cwd,
29132923 diffThemeName,
29142924 environmentId,
......@@ -3063,6 +3073,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
30633073 },
30643074 a: function MarkdownAnchor({ node, href, children, title: _title, ...props }) {
30653075 const {
3076 canOperateHost,
30663077 cwd,
30673078 environmentId,
30683079 imageBaseDir,
......@@ -3203,8 +3214,9 @@ const CHAT_MARKDOWN_COMPONENTS = {
32033214 event.stopPropagation();
32043215 const api = readLocalApi();
32053216 if (!api) return;
3206 const threadLinkAction =
3207 linkedThreadPullRequestFor(href) !== null
3217 const threadLinkAction = !canOperateHost
3218 ? undefined
3219 : linkedThreadPullRequestFor(href) !== null
32083220 ? "unlink-from-thread"
32093221 : resolveThreadPullRequest(href) === null
32103222 ? undefined
apps/web/src/components/ChatView.tsx+161-52
......@@ -48,12 +48,15 @@ import {
4848} from "../questionAttachments";
4949import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
5050import {
51 AuthOrchestrationOperateScope,
52 AuthSettingsWriteScope,
5153 type AssistantCitation,
5254 type ChatFileAttachment,
5355 CommandId,
5456 DEFAULT_MODEL,
5557 isProviderNativeSubagentThread,
5658 type ChatAttachment as ContractChatAttachment,
59 EnvironmentAuthorizationError,
5760 type EnvironmentId,
5861 type MessageId,
5962 type ModelSelection,
......@@ -285,9 +288,10 @@ import { DeviceSetup } from "./device/DeviceSetup";
285288import { Dialog } from "./ui/dialog";
286289import { WizardPopup } from "./ui/wizard";
287290import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
288import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
289291import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
290292import { isEditableFocused } from "../lib/editableFocus";
293import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
294import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings";
291295import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
292296import {
293297 AlarmClockIcon,
......@@ -555,8 +559,13 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at
555559import { appAtomRegistry } from "../rpc/atomRegistry";
556560import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
557561import { assetEnvironment } from "../state/assets";
558import { readPreparedConnection } from "../state/session";
562import {
563 readEnvironmentScope,
564 readPreparedConnection,
565 useEnvironmentScope,
566} from "../state/session";
559567import { useAtomCommand } from "../state/use-atom-command";
568import { useOrchestrationCommand } from "../state/use-orchestration-command";
560569import { useAtomQueryRunner } from "../state/use-atom-query-runner";
561570import { Button, InlineButton } from "./ui/button";
562571import {
......@@ -1535,6 +1544,7 @@ export default function ChatView(props: ChatViewProps) {
15351544 reserveTitleBarControlInset = true,
15361545 forceExpandedMobileComposer = false,
15371546 } = props;
1547 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
15381548 const draftId = routeKind === "draft" ? props.draftId : null;
15391549 const handleNewThread = useNewThreadHandler();
15401550 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
......@@ -1562,46 +1572,48 @@ export default function ChatView(props: ChatViewProps) {
15621572 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
15631573 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
15641574 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
1565 const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false });
1566 const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
1567 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1575 const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false });
1576 const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false });
1577 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
15681578 reportFailure: false,
15691579 });
15701580 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, {
15721585 reportFailure: false,
15731586 });
1574 const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, {
1587 const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
15751588 reportFailure: false,
15761589 });
1577 const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
1578 const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, {
1590 const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, {
15791591 reportFailure: false,
15801592 });
1581 const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, {
1593 const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, {
15821594 reportFailure: false,
15831595 });
15841596 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
15851597 reportFailure: false,
15861598 refresh: true,
15871599 });
1588 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
1600 const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, {
15891601 reportFailure: false,
15901602 });
15911603 const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
15921604 label: "load earlier thread history",
15931605 reportFailure: false,
15941606 });
1595 const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, {
1607 const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, {
15961608 reportFailure: false,
15971609 });
1598 const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, {
1610 const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, {
15991611 reportFailure: false,
16001612 });
16011613 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
16021614 reportFailure: false,
16031615 });
1604 const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
1616 const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
16051617 reportFailure: false,
16061618 });
16071619 const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {
......@@ -3228,6 +3240,7 @@ export default function ChatView(props: ChatViewProps) {
32283240 providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ??
32293241 activeProviderStatus;
32303242 const supportsConversationRollback =
3243 canOperateThread &&
32313244 conversationProviderStatus !== null &&
32323245 conversationProviderStatus.supportsConversationRollback !== false;
32333246 const phase = derivePhase(activeRuntime);
......@@ -4065,6 +4078,9 @@ export default function ChatView(props: ChatViewProps) {
40654078 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
40664079 });
40674080 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
4081 const scriptKeybindings =
4082 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
4083 DEFAULT_RESOLVED_KEYBINDINGS;
40684084 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
40694085 const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId);
40704086 const showOpenInPicker = shouldShowOpenInPicker({
......@@ -4497,12 +4513,14 @@ export default function ChatView(props: ChatViewProps) {
44974513 const focusComposer = useCallback(() => {
44984514 composerRef.current?.focusAtEnd();
44994515 }, [composerRef]);
4500 const canInterruptRunningThread = deriveCanInterruptRunningThread(
4501 activeThread !== undefined,
4502 activeRuntime,
4503 );
4516 const canInterruptRunningThread =
4517 canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
45044518 const onInterrupt = useCallback(async () => {
4505 if (!activeThread) return;
4519 if (
4520 !activeThread ||
4521 !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope)
4522 )
4523 return;
45064524 const result = await interruptThreadTurn({
45074525 environmentId,
45084526 input: { threadId: activeThread.id },
......@@ -4997,9 +5015,37 @@ export default function ChatView(props: ChatViewProps) {
49975015 projectCwd: string;
49985016 previousScripts: ReadonlyArray<ProjectScript>;
49995017 nextScripts: ReadonlyArray<ProjectScript>;
5000 keybinding?: string | null;
5018 keybinding: NewProjectScriptInput["keybinding"];
50015019 keybindingCommand: KeybindingCommand | null;
50025020 }): 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;
50035049 const updateResult = mapAtomCommandResult(
50045050 await updateProjectScriptSettings({
50055051 environmentId,
......@@ -5028,10 +5074,19 @@ export default function ChatView(props: ChatViewProps) {
50285074 return updateResult;
50295075 }
50305076
5031 const keybindingRule = decodeProjectScriptKeybindingRule({
5032 keybinding: input.keybinding,
5033 command: input.keybindingCommand,
5034 });
5077 if (!changesKeybinding) return updateResult;
5078 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
5079 return AsyncResult.failure(
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
50365091 if (!isElectron) return updateResult;
50375092
......@@ -6991,7 +7046,7 @@ export default function ChatView(props: ChatViewProps) {
69917046 ]);
69927047 const activeThreadSettled =
69937048 supportsSettlement && activeThreadShell?.settledOverride === "settled";
6994 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
7049 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
69957050 reportFailure: false,
69967051 });
69977052 // Keyed by thread, not a boolean: the pending state must follow the thread
......@@ -7000,7 +7055,11 @@ export default function ChatView(props: ChatViewProps) {
70007055 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);
70017056 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
70027057 const handleUnsettleActiveThread = useCallback(async () => {
7003 if (!activeThreadRef) return;
7058 if (
7059 !activeThreadRef ||
7060 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
7061 )
7062 return;
70047063 const threadKey = scopedThreadKey(activeThreadRef);
70057064 setUnsettlingThreadKey(threadKey);
70067065 try {
......@@ -7022,13 +7081,17 @@ export default function ChatView(props: ChatViewProps) {
70227081 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
70237082 }
70247083 }, [activeThreadRef, unsettleThreadMutation]);
7025 const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, {
7084 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
70267085 reportFailure: false,
70277086 });
70287087 const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null);
70297088 const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;
70307089 const handleUnsnoozeActiveThread = useCallback(async () => {
7031 if (!activeThreadRef) return;
7090 if (
7091 !activeThreadRef ||
7092 !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope)
7093 )
7094 return;
70327095 const threadKey = scopedThreadKey(activeThreadRef);
70337096 setUnsnoozingThreadKey(threadKey);
70347097 try {
......@@ -7175,7 +7238,8 @@ export default function ChatView(props: ChatViewProps) {
71757238 const isStoppingBackgroundWork =
71767239 stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;
71777240 const handleStopBackgroundWork = useCallback(async () => {
7178 if (!activeThread) return;
7241 if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
7242 return;
71797243 const requestKey = `${environmentId}:${activeThread.id}`;
71807244 setStoppingBackgroundWorkKey(requestKey);
71817245 const result = await interruptThreadTurn({
......@@ -7377,7 +7441,7 @@ export default function ChatView(props: ChatViewProps) {
73777441 <Button
73787442 size="xs"
73797443 variant="ghost"
7380 disabled={isStoppingBackgroundWork}
7444 disabled={!canOperateThread || isStoppingBackgroundWork}
73817445 onClick={() => void handleStopBackgroundWork()}
73827446 >
73837447 {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
......@@ -7387,6 +7451,7 @@ export default function ChatView(props: ChatViewProps) {
73877451 }, [
73887452 activeBackgroundTasks,
73897453 activeThread,
7454 canOperateThread,
73907455 handleStopBackgroundWork,
73917456 isStoppingBackgroundWork,
73927457 onOpenRelatedThread,
......@@ -7423,7 +7488,7 @@ export default function ChatView(props: ChatViewProps) {
74237488 <Button
74247489 size="xs"
74257490 variant="ghost"
7426 disabled={isSnoozed ? isUnsnoozing : isUnsettling}
7491 disabled={!canOperateThread || (isSnoozed ? isUnsnoozing : isUnsettling)}
74277492 onClick={() =>
74287493 void (isSnoozed ? handleUnsnoozeActiveThread() : handleUnsettleActiveThread())
74297494 }
......@@ -7442,6 +7507,7 @@ export default function ChatView(props: ChatViewProps) {
74427507 activeThread?.id,
74437508 activeThreadSettled,
74447509 activeThreadSnoozed,
7510 canOperateThread,
74457511 handleUnsnoozeActiveThread,
74467512 handleUnsettleActiveThread,
74477513 isUnsnoozing,
......@@ -7463,6 +7529,7 @@ export default function ChatView(props: ChatViewProps) {
74637529 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
74647530 );
74657531 const compactThreadUnavailable =
7532 !canOperateThread ||
74667533 !activeThread ||
74677534 !activeThreadHasCompactableConversation ||
74687535 !activeProject ||
......@@ -7479,11 +7546,13 @@ export default function ChatView(props: ChatViewProps) {
74797546 showPlanFollowUpPrompt;
74807547 const compactDisabled = compactThreadUnavailable;
74817548 const compactDisabledReason = compactDisabled
7482 ? !activeProject
7483 ? "Choose a project before compacting"
7484 : !manualCompactionProviderAvailable
7485 ? "Compaction is unavailable for this provider"
7486 : "Compacting is unavailable right now"
7549 ? !canOperateThread
7550 ? "This connection cannot change threads."
7551 : !activeProject
7552 ? "Choose a project before compacting"
7553 : !manualCompactionProviderAvailable
7554 ? "Compaction is unavailable for this provider"
7555 : "Compacting is unavailable right now"
74877556 : null;
74887557 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
74897558 if (
......@@ -7813,7 +7882,7 @@ export default function ChatView(props: ChatViewProps) {
78137882 }
78147883 }
78157884
7816 const command = resolveShortcutCommand(event, keybindings, {
7885 const command = resolveChatShortcutCommand(event, keybindings, scriptKeybindings, {
78177886 context: shortcutContext,
78187887 });
78197888 if (!command) return;
......@@ -7826,6 +7895,7 @@ export default function ChatView(props: ChatViewProps) {
78267895 }
78277896
78287897 if (command === "thread.settle") {
7898 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
78297899 event.preventDefault();
78307900 event.stopPropagation();
78317901 if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
......@@ -7849,6 +7919,7 @@ export default function ChatView(props: ChatViewProps) {
78497919 }
78507920
78517921 if (command === "thread.pin") {
7922 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
78527923 event.preventDefault();
78537924 event.stopPropagation();
78547925 if (!isServerThread || !activeThreadRef || !supportsPinning) return;
......@@ -8069,6 +8140,7 @@ export default function ChatView(props: ChatViewProps) {
80698140 splitTerminal,
80708141 splitPanelTerminal,
80718142 keybindings,
8143 scriptKeybindings,
80728144 handleUnsettleActiveThread,
80738145 isServerThread,
80748146 onInterrupt,
......@@ -8143,7 +8215,13 @@ export default function ChatView(props: ChatViewProps) {
81438215 const onRevertToTurnCount = useCallback(
81448216 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
81458217 const localApi = readLocalApi();
8146 if (!localApi || !activeThread || isRevertingCheckpoint) return;
8218 if (
8219 !localApi ||
8220 !activeThread ||
8221 isRevertingCheckpoint ||
8222 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
8223 )
8224 return;
81478225 const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);
81488226 const message = sourceMessage
81498227 ? {
......@@ -8514,6 +8592,7 @@ export default function ChatView(props: ChatViewProps) {
85148592 );
85158593 };
85168594 if (
8595 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
85178596 !activeThread ||
85188597 isSendBusy ||
85198598 isConnecting ||
......@@ -9141,6 +9220,23 @@ export default function ChatView(props: ChatViewProps) {
91419220 void dockTransition.catch(() => resolveDockStarted?.());
91429221 await dockStarted;
91439222 }
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 }
91449240 beginLocalDispatch({
91459241 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
91469242 // Only a draft has a background submission to hide behind its hero.
......@@ -9815,7 +9911,8 @@ export default function ChatView(props: ChatViewProps) {
98159911
98169912 const onRespondToApproval = useCallback(
98179913 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
9818 if (!activeThreadId) return;
9914 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
9915 return;
98199916 if (
98209917 pendingApprovals.find((approval) => approval.requestId === requestId)
98219918 ?.responseCapability !== "live"
......@@ -9848,7 +9945,8 @@ export default function ChatView(props: ChatViewProps) {
98489945
98499946 const onRespondToUserInput = useCallback(
98509947 async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {
9851 if (!activeThreadId) return;
9948 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
9949 return;
98529950 const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
98539951 if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
98549952 const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
......@@ -10079,7 +10177,14 @@ export default function ChatView(props: ChatViewProps) {
1007910177 context?: ReturnType<typeof buildMessageContext>;
1008010178 interactionMode: "default" | "plan";
1008110179 }) {
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 ) {
1008310188 return false;
1008410189 }
1008510190
......@@ -10213,6 +10318,7 @@ export default function ChatView(props: ChatViewProps) {
1021310318
1021410319 const onImplementPlanInNewThread = useCallback(async () => {
1021510320 if (
10321 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
1021610322 !activeThread ||
1021710323 !activeProject ||
1021810324 !activeProposedPlan ||
......@@ -11264,6 +11370,7 @@ export default function ChatView(props: ChatViewProps) {
1126411370 ) : null}
1126511371 {!composerMounted ? null : (
1126611372 <ChatComposer
11373 canOperateThread={canOperateThread}
1126711374 reportedModelSelection={reportedModelSelection}
1126811375 multipleModelSelections={multipleModelSelections}
1126911376 supportsMultipleModels={
......@@ -11301,17 +11408,19 @@ export default function ChatView(props: ChatViewProps) {
1130111408 canResume={resumableRunId !== null || hasHeldQueuedRuns}
1130211409 isRevertingCheckpoint={isRevertingCheckpoint}
1130311410 sendDisabledReason={
11304 isEnvironmentChanging
11305 ? "Preparing machine"
11306 : isRevertingCheckpoint
11307 ? "Rewinding conversation"
11308 : feedbackUploading
11309 ? "Sending feedback"
11310 : threadDetailLoading
11311 ? "Messages loading"
11312 : worktreeSetupBlocksSend
11313 ? "Preparing worktree"
11314 : projectCloneSendBlockReason
11411 !canOperateThread
11412 ? "This connection cannot change threads."
11413 : isEnvironmentChanging
11414 ? "Preparing machine"
11415 : isRevertingCheckpoint
11416 ? "Rewinding conversation"
11417 : feedbackUploading
11418 ? "Sending feedback"
11419 : threadDetailLoading
11420 ? "Messages loading"
11421 : worktreeSetupBlocksSend
11422 ? "Preparing worktree"
11423 : projectCloneSendBlockReason
1131511424 }
1131611425 isPreparingWorktree={isPreparingWorktree}
1131711426 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-
4545import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
4646import { CSS } from "@dnd-kit/utilities";
4747import {
48 AuthOrchestrationOperateScope,
4849 type ContextMenuItem,
50 type EnvironmentId,
4951 ProjectId,
5052 type ScopedThreadRef,
5153 type ResolvedKeybindingsConfig,
......@@ -96,7 +98,12 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";
9698import { useThreadDiscoveredPorts } from "../portDiscoveryState";
9799import { openDiscoveredPort } from "./preview/openDiscoveredPort";
98100import { useAtomCommand } from "../state/use-atom-command";
101import {
102 ORCHESTRATION_OPERATE_DENIED_MESSAGE,
103 useOrchestrationCommand,
104} from "../state/use-orchestration-command";
99105import { previewEnvironment } from "../state/preview";
106import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
100107import {
101108 legacyProjectCwdPreferenceKey,
102109 resolveProjectExpanded,
......@@ -357,6 +364,18 @@ interface SidebarThreadRowProps {
357364 onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void;
358365}
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
360379const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
361380 const {
362381 orderedProjectThreadKeys,
......@@ -385,6 +404,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
385404 onFileDropThreads,
386405 thread,
387406 } = props;
407 const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
388408 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
389409 const threadKey = scopedThreadKey(threadRef);
390410 const [isFileDragOver, setIsFileDragOver] = useState(false);
......@@ -485,10 +505,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
485505 : null;
486506 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
487507 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
488 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
508 const isConfirmingArchive =
509 canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
489510 const threadMetaClassName = isConfirmingArchive
490511 ? "pointer-events-none opacity-0"
491 : !isThreadRunning
512 : canOperateThread && !isThreadRunning
492513 ? "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"
493514 : "pointer-events-none";
494515 const clearConfirmingArchive = useCallback(() => {
......@@ -517,6 +538,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
517538 );
518539 const handleRowDoubleClick = useCallback(
519540 (event: React.MouseEvent) => {
541 if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return;
520542 // Already renaming this row: a double-click on the row chrome (outside the
521543 // input) must not restart and discard the in-progress edit.
522544 if (renamingThreadKey === threadKey) return;
......@@ -531,7 +553,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
531553 event.preventDefault();
532554 startThreadRename(threadKey, thread.title);
533555 },
534 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title],
556 [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.environmentId, thread.title],
535557 );
536558 const handleRowKeyDown = useCallback(
537559 (event: React.KeyboardEvent) => {
......@@ -685,12 +707,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
685707 (event: React.MouseEvent<HTMLButtonElement>) => {
686708 event.preventDefault();
687709 event.stopPropagation();
710 if (!checkTaskPermission(thread.environmentId)) return;
688711 setConfirmingArchiveThreadKey(threadKey);
689712 requestAnimationFrame(() => {
690713 confirmArchiveButtonRefs.current.get(threadKey)?.focus();
691714 });
692715 },
693 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey],
716 [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey, thread.environmentId],
694717 );
695718 const handleArchiveImmediateClick = useCallback(
696719 (event: React.MouseEvent<HTMLButtonElement>) => {
......@@ -775,7 +798,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
775798 </a>
776799 ) : null}
777800 {threadStatus && <ThreadStatusLabel status={threadStatus} />}
778 {renamingThreadKey === threadKey ? (
801 {canOperateThread && renamingThreadKey === threadKey ? (
779802 <input
780803 ref={handleRenameInputRef}
781804 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
861884 >
862885 Confirm
863886 </button>
864 ) : !isThreadRunning ? (
887 ) : canOperateThread && !isThreadRunning ? (
865888 appSettingsConfirmThreadArchive ? (
866889 <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">
867890 <button
......@@ -1191,13 +1214,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
11911214 (settings) => settings.confirmThreadArchive,
11921215 );
11931216 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
1194 const deleteProject = useAtomCommand(projectEnvironment.delete, {
1217 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
11951218 reportFailure: false,
11961219 });
1197 const updateProject = useAtomCommand(projectEnvironment.update, {
1220 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
11981221 reportFailure: false,
11991222 });
1200 const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
1223 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
12011224 reportFailure: false,
12021225 });
12031226 const updateSettings = useUpdateClientSettings();
......@@ -1293,6 +1316,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
12931316 null,
12941317 );
12951318 const [projectRenameTitle, setProjectRenameTitle] = useState("");
1319 const canRenameProject = useEnvironmentScope(
1320 projectRenameTarget?.environmentId ?? null,
1321 AuthOrchestrationOperateScope,
1322 );
12961323 const [projectGroupingTarget, setProjectGroupingTarget] =
12971324 useState<SidebarProjectGroupMember | null>(null);
12981325 const [projectGroupingSelection, setProjectGroupingSelection] = useState<
......@@ -1501,6 +1528,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15011528 );
15021529
15031530 const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => {
1531 if (!checkTaskPermission(member.environmentId)) return;
15041532 setProjectRenameTarget(member);
15051533 setProjectRenameTitle(member.title);
15061534 }, []);
......@@ -1551,6 +1579,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15511579
15521580 const handleRemoveProject = useCallback(
15531581 async (member: SidebarProjectGroupMember) => {
1582 if (!checkTaskPermission(member.environmentId)) return;
15541583 const api = readLocalApi();
15551584 if (!api) {
15561585 return;
......@@ -1573,6 +1602,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15731602 await new Promise<void>((resolve) => {
15741603 window.setTimeout(resolve, 180);
15751604 });
1605 if (!checkTaskPermission(member.environmentId)) return;
15761606
15771607 const latestProjectThreads = Array.from(
15781608 sidebarThreadByKeyRef.current.values(),
......@@ -1765,12 +1795,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
17651795
17661796 const clicked = await api.contextMenu.show(
17671797 [
1768 buildTargetedItem("rename", "Rename"),
1798 buildTargetedItem("rename", "Rename", {
1799 isDisabled: (member) =>
1800 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
1801 }),
17691802 buildTargetedItem("grouping", "Group into..."),
17701803 buildTargetedItem("copy-path", "Copy Path"),
17711804 { id: "project-settings", label: "Project settings", icon: "settings" },
17721805 buildTargetedItem("delete", "Remove", {
17731806 destructive: true,
1807 isDisabled: (member) =>
1808 !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope),
17741809 }),
17751810 ],
17761811 {
......@@ -1907,9 +1942,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19071942 const hasRunningThread = selectedThreadEntries.some(
19081943 ({ thread }) => !threadRuntimeCanArchive(thread.runtime),
19091944 );
1945 const canOperateSelection = selectedThreadEntries.every(({ threadRef }) =>
1946 readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope),
1947 );
19101948
19111949 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 ),
19131955 position,
19141956 );
19151957
......@@ -1921,6 +1963,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19211963 return;
19221964 }
19231965
1966 if (
1967 (clicked === "archive" || clicked === "delete") &&
1968 !selectedThreadEntries.every(({ threadRef }) =>
1969 checkTaskPermission(threadRef.environmentId),
1970 )
1971 ) {
1972 return;
1973 }
1974
19241975 if (clicked === "archive") {
19251976 if (appSettingsConfirmThreadArchive) {
19261977 const confirmed = await api.dialogs.confirm(
......@@ -1928,6 +1979,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19281979 );
19291980 if (!confirmed) return;
19301981 }
1982 if (
1983 !selectedThreadEntries.every(({ threadRef }) =>
1984 checkTaskPermission(threadRef.environmentId),
1985 )
1986 ) {
1987 return;
1988 }
19311989
19321990 const archiveOutcome = await archiveSelectedThreadEntries({
19331991 entries: selectedThreadEntries,
......@@ -1974,6 +2032,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
19742032 );
19752033 if (!confirmed) return;
19762034 }
2035 if (
2036 !selectedThreadEntries.every(({ threadRef }) =>
2037 checkTaskPermission(threadRef.environmentId),
2038 )
2039 ) {
2040 return;
2041 }
19772042
19782043 const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({
19792044 entries: selectedThreadEntries,
......@@ -2090,6 +2155,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
20902155
20912156 const attemptArchiveThread = useCallback(
20922157 async (threadRef: ScopedThreadRef) => {
2158 if (!checkTaskPermission(threadRef.environmentId)) return;
20932159 const result = await archiveThread(threadRef);
20942160 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
20952161 const error = squashAtomCommandFailure(result);
......@@ -2111,6 +2177,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
21112177 }, []);
21122178
21132179 const startThreadRename = useCallback((threadKey: string, title: string) => {
2180 const threadRef = parseScopedThreadKey(threadKey);
2181 if (!threadRef || !checkTaskPermission(threadRef.environmentId)) return;
21142182 setRenamingThreadKey(threadKey);
21152183 setRenamingTitle(title);
21162184 renamingCommittedRef.current = false;
......@@ -2250,17 +2318,27 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
22502318 );
22512319 const threadWorkspacePath =
22522320 thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null;
2321 const canOperateThread = readEnvironmentScope(
2322 thread.environmentId,
2323 AuthOrchestrationOperateScope,
2324 );
22532325 const clicked = await api.contextMenu.show(
22542326 [
22552327 ...(thread.branch
22562328 ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }]
22572329 : []),
2258 { id: "rename", label: "Rename thread" },
2330 { id: "rename", label: "Rename thread", disabled: !canOperateThread },
22592331 { id: "mark-unread", label: "Mark unread" },
22602332 { id: "copy-path", label: "Copy Path" },
22612333 { id: "copy-thread-id", label: "Copy Thread ID" },
22622334 { 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 },
22642342 ],
22652343 position,
22662344 );
......@@ -2326,6 +2404,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23262404 return;
23272405 }
23282406 if (clicked !== "delete") return;
2407 if (!checkTaskPermission(threadRef.environmentId)) return;
23292408 if (appSettingsConfirmThreadDelete) {
23302409 const confirmed = await api.dialogs.confirm(
23312410 [
......@@ -2338,6 +2417,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
23382417 return;
23392418 }
23402419 }
2420 if (!checkTaskPermission(threadRef.environmentId)) return;
23412421 const result = await deleteThread(threadRef);
23422422 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
23432423 const error = squashAtomCommandFailure(result);
......@@ -2533,6 +2613,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
25332613 <span className="text-xs font-medium text-foreground">Project title</span>
25342614 <Input
25352615 aria-label="Project title"
2616 disabled={!canRenameProject}
25362617 value={projectRenameTitle}
25372618 onChange={(event) => setProjectRenameTitle(event.target.value)}
25382619 onKeyDown={(event) => {
......@@ -2548,12 +2629,19 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
25482629 Environment: {projectRenameTarget.environmentLabel}
25492630 </p>
25502631 ) : null}
2632 {!canRenameProject ? (
2633 <p className="text-xs text-muted-foreground">
2634 This connection cannot change projects.
2635 </p>
2636 ) : null}
25512637 </DialogPanel>
25522638 <DialogFooter>
25532639 <Button variant="outline" onClick={closeProjectRenameDialog}>
25542640 Cancel
25552641 </Button>
2556 <Button onClick={() => void submitProjectRename()}>Save</Button>
2642 <Button disabled={!canRenameProject} onClick={() => void submitProjectRename()}>
2643 Save
2644 </Button>
25572645 </DialogFooter>
25582646 </DialogPopup>
25592647 </Dialog>
apps/web/src/components/ProjectScriptsControl.tsx+17-7
......@@ -1,9 +1,13 @@
11import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
2import type {
3 ProjectScript,
4 ResolvedKeybindingsConfig,
5 T3ProjectFileScript,
2import {
3 AuthSettingsWriteScope,
4 type EnvironmentId,
5 type ProjectScript,
6 type T3ProjectFileScript,
7 type ResolvedKeybindingsConfig,
68} from "@t3tools/contracts";
9import { useAtomValue } from "@effect/atom-react";
10import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
711import {
812 isAtomCommandInterrupted,
913 squashAtomCommandFailure,
......@@ -13,6 +17,8 @@ import React, { useCallback, useMemo, useState } from "react";
1317
1418import { commandForProjectScript, primaryProjectScript } from "~/projectScripts";
1519import { shortcutLabelForCommand } from "~/keybindings";
20import { serverEnvironment } from "~/state/server";
21import { readEnvironmentScope } from "~/state/session";
1622import {
1723 EMPTY_PROJECT_SCRIPT_INPUT,
1824 editorRequestForScript,
......@@ -55,10 +61,10 @@ interface ProjectScriptsControlProps {
5561 displayMode?: "toolbar" | "panel";
5662 presentation?: "toolbar" | "menu";
5763 onRequestMenuClose?: () => void;
64 environmentId: EnvironmentId;
5865 scripts: ReadonlyArray<ProjectScript>;
5966 /** Scripts declared in the project's checked-in t3.json, offered for import. */
6067 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
61 keybindings: ResolvedKeybindingsConfig;
6268 preferredScriptId?: string | null;
6369 onRunScript: (script: ProjectScript) => void;
6470 onAddScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
......@@ -73,9 +79,9 @@ export default function ProjectScriptsControl({
7379 displayMode = "toolbar",
7480 presentation = "toolbar",
7581 onRequestMenuClose,
82 environmentId,
7683 scripts,
7784 fileScripts = NO_FILE_SCRIPTS,
78 keybindings,
7985 preferredScriptId = null,
8086 onRunScript,
8187 onAddScript,
......@@ -85,6 +91,9 @@ export default function ProjectScriptsControl({
8591 const isPanel = displayMode === "panel";
8692 const ActionGroup = isPanel ? "div" : Group;
8793 const panelAnchorRef = React.useRef<HTMLDivElement | null>(null);
94 const keybindings =
95 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
96 DEFAULT_RESOLVED_KEYBINDINGS;
8897 const [actionsMenuOpen, setActionsMenuOpen] = useState({
8998 presentation,
9099 scripts: false,
......@@ -138,7 +147,7 @@ export default function ProjectScriptsControl({
138147 icon: fileScript.icon ?? "play",
139148 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
140149 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
141 keybinding: null,
150 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
142151 previewUrl: fileScript.previewUrl ?? null,
143152 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
144153 };
......@@ -459,6 +468,7 @@ export default function ProjectScriptsControl({
459468 )}
460469
461470 <ProjectScriptEditorDialog
471 environmentId={environmentId}
462472 request={editorRequest}
463473 scripts={scripts}
464474 onSubmit={submitScript}
apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx+14
......@@ -1,6 +1,8 @@
11import type { Dispatch, ReactElement, SetStateAction } from "react";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
33import {
4 AuthProvidersManageScope,
5 type AuthEnvironmentScope,
46 type EnvironmentId,
57 ProviderDriverKind,
68 ProviderInstanceId,
......@@ -16,6 +18,7 @@ import type {
1618
1719const testState = vi.hoisted(() => ({
1820 groups: [] as LocalEnvironmentUpdateGroup[],
21 permittedEnvironmentIds: new Set<EnvironmentId>(),
1922 updateProvider: vi.fn(),
2023}));
2124
......@@ -90,6 +93,15 @@ vi.mock("~/state/server", () => ({
9093 serverEnvironment: { updateProvider: Symbol("updateProvider") },
9194}));
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
93105vi.mock("~/state/use-atom-command", () => ({
94106 useAtomCommand: () => testState.updateProvider,
95107}));
......@@ -176,6 +188,8 @@ describe("ProviderUpdateEnvironmentRows", () => {
176188 beforeEach(() => {
177189 vi.useFakeTimers();
178190 hooks.reset();
191 testState.permittedEnvironmentIds.clear();
192 testState.permittedEnvironmentIds.add(environmentId);
179193 testState.updateProvider.mockReset();
180194 const candidate = provider() as ProviderUpdateCandidate;
181195 testState.groups = [
apps/web/src/components/ProviderUpdateEnvironmentRows.tsx+15-3
......@@ -1,6 +1,10 @@
11import { CheckIcon } from "lucide-react";
22import { 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";
48import {
59 isAtomCommandInterrupted,
610 squashAtomCommandFailure,
......@@ -9,6 +13,7 @@ import {
913
1014import { cn } from "~/lib/utils";
1115import { serverEnvironment } from "~/state/server";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
1217import { useAtomCommand } from "~/state/use-atom-command";
1318import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
1419import {
......@@ -115,6 +120,7 @@ function EnvironmentUpdateRow({
115120 readonly status: ProviderUpdateRowStatus;
116121 readonly onUpdate: () => void;
117122}) {
123 const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope);
118124 let trailing: ReactNode;
119125 switch (status.kind) {
120126 case "loading":
......@@ -126,14 +132,14 @@ function EnvironmentUpdateRow({
126132 case "failed":
127133 case "unchanged":
128134 trailing = (
129 <Button size="xs" variant="outline" onClick={onUpdate}>
135 <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}>
130136 Retry
131137 </Button>
132138 );
133139 break;
134140 default:
135141 trailing = (
136 <Button size="xs" variant="outline" onClick={onUpdate}>
142 <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}>
137143 Update
138144 </Button>
139145 );
......@@ -145,6 +151,11 @@ function EnvironmentUpdateRow({
145151 <div className="flex min-w-0 flex-col">
146152 <span className="truncate font-medium text-foreground">{group.label}</span>
147153 <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}
148159 </div>
149160 <div className="shrink-0">{trailing}</div>
150161 </div>
......@@ -211,6 +222,7 @@ export function ProviderUpdateEnvironmentRows({
211222
212223 const handleUpdate = useCallback(
213224 async (environmentId: EnvironmentId) => {
225 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
214226 const group = groupByEnvironment.get(environmentId);
215227 if (!group || group.candidates.length === 0) {
216228 return;
apps/web/src/components/ProviderUpdatePrimaryNotification.tsx+83-39
......@@ -2,10 +2,15 @@ import { useNavigate } from "@tanstack/react-router";
22import { useAtomValue } from "@effect/atom-react";
33import { DownloadIcon } from "lucide-react";
44import { 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
711import { primaryServerProvidersAtom, serverEnvironment } from "../state/server";
812import { usePrimaryEnvironment } from "../state/environments";
13import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
914import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal";
1015import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
1116import {
......@@ -27,7 +32,12 @@ const seenProviderUpdateNotificationKeys = new Set<string>();
2732type ProviderUpdateToastId = ReturnType<typeof toastManager.add>;
2833
2934type 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 }
3141 | {
3242 readonly kind: "update";
3343 readonly key: string;
......@@ -100,6 +110,10 @@ export function ProviderUpdatePrimaryNotification() {
100110 const navigate = useNavigate();
101111 const providers = useAtomValue(primaryServerProvidersAtom);
102112 const primaryEnvironment = usePrimaryEnvironment();
113 const canManageProviders = useEnvironmentScope(
114 primaryEnvironment?.environmentId ?? null,
115 AuthProvidersManageScope,
116 );
103117 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
104118 reportFailure: false,
105119 });
......@@ -178,11 +192,15 @@ export function ProviderUpdatePrimaryNotification() {
178192 activeToastRef.current = null;
179193 }
180194
195 const updateExistingPrompt =
196 activeToast?.kind === "prompt" &&
197 activeToast.key === notificationKey &&
198 activeToast.canManageProviders !== canManageProviders;
181199 if (
182200 !notificationKey ||
183201 dismissedNotificationKeys.has(notificationKey) ||
184 seenProviderUpdateNotificationKeys.has(notificationKey) ||
185 activeToastRef.current
202 (seenProviderUpdateNotificationKeys.has(notificationKey) && !updateExistingPrompt) ||
203 (activeToastRef.current && !updateExistingPrompt)
186204 ) {
187205 return;
188206 }
......@@ -199,7 +217,12 @@ export function ProviderUpdatePrimaryNotification() {
199217 };
200218
201219 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 ) {
203226 return;
204227 }
205228 updateStarted = true;
......@@ -219,6 +242,19 @@ export function ProviderUpdatePrimaryNotification() {
219242 void (async () => {
220243 const results = [];
221244 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 }
222258 results.push(
223259 await updateProvider({
224260 environmentId: primaryEnvironment.environmentId,
......@@ -260,44 +296,52 @@ export function ProviderUpdatePrimaryNotification() {
260296 })();
261297 };
262298
263 toastId = toastManager.add(
264 stackedThreadToast({
265 type: initialView.type,
266 title: initialView.title,
267 description: initialView.description,
268 timeout: 0,
269 actionProps:
270 oneClickProviders.length > 0
271 ? {
272 children: "Update",
273 onClick: runUpdates,
274 }
275 : {
299 const toastOptions = stackedThreadToast({
300 type: initialView.type,
301 title: initialView.title,
302 description: canManageProviders
303 ? initialView.description
304 : "This connection cannot manage provider accounts. View provider settings for update details.",
305 timeout: 0,
306 actionProps:
307 oneClickProviders.length > 0
308 ? {
309 children: "Update",
310 disabled: !canManageProviders,
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: {
276328 children: "Settings",
277329 onClick: openSettings,
278330 },
279 actionVariant: "outline",
280 data: {
281 leadingIcon:
282 updateProviders.length === 1 ? (
283 <ProviderUpdateToastIcon provider={updateProviders[0]!} />
284 ) : undefined,
285 hideCopyButton: true,
286 onClose: dismissPrompt,
287 ...(oneClickProviders.length > 0
288 ? {
289 secondaryActionProps: {
290 children: "Settings",
291 onClick: openSettings,
292 },
293 secondaryActionVariant: "outline" as const,
294 }
295 : {}),
296 },
297 }),
298 );
299 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId };
331 secondaryActionVariant: "outline" as const,
332 }
333 : {}),
334 },
335 });
336 if (updateExistingPrompt && activeToast?.kind === "prompt") {
337 toastId = activeToast.toastId;
338 toastManager.update(toastId, toastOptions);
339 } else {
340 toastId = toastManager.add(toastOptions);
341 }
342 activeToastRef.current = { kind: "prompt", key: notificationKey, toastId, canManageProviders };
300343 }, [
344 canManageProviders,
301345 updateProvider,
302346 dismissNotificationKey,
303347 dismissedNotificationKeys,
apps/web/src/components/ServerUpdateAction.test.tsx+87-1
......@@ -1,8 +1,9 @@
11import { act, type ReactElement } from "react";
22import { create, type ReactTestRenderer } from "react-test-renderer";
33import { renderToStaticMarkup } from "react-dom/server";
4import type { EnvironmentId, ServerInstallation } from "@t3tools/contracts";
4import { AuthSessionState, type EnvironmentId, type ServerInstallation } from "@t3tools/contracts";
55import * as Cause from "effect/Cause";
6import * as Schema from "effect/Schema";
67import { AsyncResult } from "effect/reactivity";
78import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
89
......@@ -11,6 +12,8 @@ const testState = vi.hoisted(() => ({
1112 toast: vi.fn(),
1213 clipboard: vi.fn(),
1314 continueThreadsAfterServerUpdate: false,
15 session: null as AsyncResult.AsyncResult<AuthSessionState, Error> | null,
16 sessionAtom: Symbol("session"),
1417}));
1518
1619vi.mock("~/hooks/useCopyToClipboard", () => ({
......@@ -27,6 +30,13 @@ vi.mock("~/hooks/useSettings", () => ({
2730 selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown,
2831 ) => selector({ continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate }),
2932}));
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}));
3040vi.mock("~/state/server", () => ({
3141 serverEnvironment: { updateServer: Symbol("updateServer") },
3242}));
......@@ -50,6 +60,8 @@ import {
5060 type ServerUpdateTarget,
5161} from "./ServerUpdateAction";
5262
63const decodeSessionState = Schema.decodeUnknownSync(AuthSessionState);
64
5365type ActionElement = ReactElement<{
5466 readonly onClick?: () => void;
5567}>;
......@@ -68,12 +80,86 @@ async function flushPromises(): Promise<void> {
6880 await Promise.resolve();
6981}
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
7199describe("ServerUpdateAction", () => {
72100 beforeEach(() => {
73101 testState.updateServer.mockReset();
74102 testState.toast.mockReset();
75103 testState.clipboard.mockReset();
76104 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();
77163 });
78164
79165 it.each([
apps/web/src/components/ServerUpdateAction.tsx+36-3
......@@ -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";
15import type {
26 EnvironmentId,
37 ServerInstallation,
......@@ -15,6 +19,7 @@ import { requestConfirmDialog } from "~/confirmDialog";
1519import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
1620import { useEnvironmentSettings } from "~/hooks/useSettings";
1721import { serverEnvironment, updateOutdatedServer } from "~/state/server";
22import { appAtomRegistry } from "~/rpc/atomRegistry";
1823import { useAtomCommand } from "~/state/use-atom-command";
1924import { manualServerUpdateCommand } from "~/versionSkew";
2025import { Button } from "./ui/button";
......@@ -60,7 +65,11 @@ function useServerUpdate() {
6065 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
6166 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
6267 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;
6473 pendingUpdateEnvironmentIds.add(environmentId);
6574 try {
6675 const result = await updateServer({
......@@ -151,6 +160,17 @@ export function ServerUpdatesAction({
151160 );
152161}
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
154174/**
155175 * One-row status for an in-flight server update: "Downloading…" then
156176 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
......@@ -204,6 +224,8 @@ export function ServerUpdateAction({
204224 appearance = "button",
205225}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
206226 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
227 const sessionStateAtom = environmentSession.sessionStateAtom(environmentId);
228 const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom));
207229 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
208230 environmentId,
209231 (settings) => settings.continueThreadsAfterServerUpdate,
......@@ -232,7 +254,10 @@ export function ServerUpdateAction({
232254 });
233255
234256 const handleUpdate = async () => {
235 if (pendingUpdateEnvironmentIds.has(environmentId)) {
257 if (
258 !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) ||
259 pendingUpdateEnvironmentIds.has(environmentId)
260 ) {
236261 return;
237262 }
238263 if (isDesktopAppUpdate) {
......@@ -247,6 +272,7 @@ export function ServerUpdateAction({
247272 return;
248273 }
249274 }
275 if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return;
250276 await update({
251277 environmentId,
252278 serverLabel,
......@@ -289,6 +315,7 @@ export function ServerUpdateAction({
289315 variant="ghost-muted"
290316 className={className}
291317 aria-label={`${actionLabel} for ${serverLabel}`}
318 disabled={manualCommand === null && !canUpdate}
292319 onClick={onClick}
293320 />
294321 }
......@@ -301,7 +328,13 @@ export function ServerUpdateAction({
301328 }
302329
303330 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 >
305338 {actionLabel}
306339 </Button>
307340 );
apps/web/src/components/chat/ChatComposer.tsx+26-1
......@@ -38,6 +38,7 @@ import type {
3838 SnapShotSource,
3939} from "@t3tools/contracts";
4040import {
41 AuthOrchestrationOperateScope,
4142 ProviderDriverKind,
4243 ProviderInstanceId,
4344 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
......@@ -1130,6 +1131,7 @@ import { useDelayedStatus } from "../../hooks/useDelayedStatus";
11301131import { useMediaQuery } from "../../hooks/useMediaQuery";
11311132import { usePanelAnimationSettings } from "../../panelAnimations";
11321133import { useAtomCommand } from "../../state/use-atom-command";
1134import { readEnvironmentScope } from "../../state/session";
11331135import { serverEnvironment } from "../../state/server";
11341136import type { ReviewCommentContext } from "../../reviewCommentContext";
11351137
......@@ -1348,6 +1350,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
13481350
13491351const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: {
13501352 compact: boolean;
1353 canOperateThread: boolean;
13511354 activeContextWindow: ContextWindowSnapshot | null;
13521355 reserveContextWindowMeter: boolean;
13531356 activeThreadModelDisplayName: string | null;
......@@ -1397,6 +1400,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
13971400 ) : null}
13981401 <ComposerPrimaryActions
13991402 compact={props.compact}
1403 canOperateThread={props.canOperateThread}
14001404 pendingAction={props.pendingAction}
14011405 isRunning={props.isRunning}
14021406 canInterrupt={props.canInterrupt}
......@@ -1499,6 +1503,7 @@ export interface ChatComposerHandle {
14991503export interface ChatComposerProps {
15001504 composerDraftTarget: ScopedThreadRef | DraftId;
15011505 environmentId: EnvironmentId;
1506 canOperateThread: boolean;
15021507 attachmentUploadsCapabilityKnown: boolean;
15031508 supportsAttachmentUploads: boolean;
15041509 supportsQuestionAttachments: boolean;
......@@ -1685,6 +1690,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16851690 const {
16861691 composerDraftTarget,
16871692 environmentId,
1693 canOperateThread,
16881694 attachmentUploadsCapabilityKnown,
16891695 supportsAttachmentUploads,
16901696 supportsQuestionAttachments,
......@@ -2001,6 +2007,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20012007 }
20022008 return;
20032009 }
2010 if (!canOperateThread) return;
20042011 const invalidFiles =
20052012 maxFileAttachmentBytes === null
20062013 ? composerFiles
......@@ -2027,6 +2034,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20272034 }, [
20282035 attachmentUploadsCapabilityKnown,
20292036 attachmentDraftTarget,
2037 canOperateThread,
20302038 composerFiles,
20312039 composerImages,
20322040 environmentId,
......@@ -4147,7 +4155,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41474155 dispatchMode?: ComposerDispatchMode,
41484156 submissionIntent?: ComposerSubmissionIntent,
41494157 ) => {
4150 if (noProviderAvailable || isSendDisabled) {
4158 if (
4159 noProviderAvailable ||
4160 isSendDisabled ||
4161 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
4162 ) {
41514163 event?.preventDefault();
41524164 return;
41534165 }
......@@ -4210,6 +4222,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42104222 activePendingProgress,
42114223 attachmentTargetKey,
42124224 blurMobileComposerAfterSend,
4225 environmentId,
42134226 isSendDisabled,
42144227 noProviderAvailable,
42154228 onSend,
......@@ -4245,6 +4258,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42454258 }, [phase, settings.followUpBehavior, submitComposer]);
42464259 const compactThreadContext = useCallback(() => {
42474260 if (
4261 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
42484262 compactDisabled ||
42494263 noProviderAvailable ||
42504264 activePendingApproval !== null ||
......@@ -4261,6 +4275,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42614275 activePendingApproval,
42624276 activeThreadId,
42634277 compactDisabled,
4278 composerDraftTarget,
4279 environmentId,
42644280 isConnecting,
42654281 isSendBusy,
42664282 noProviderAvailable,
......@@ -6650,6 +6666,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66506666 <ComposerBanner.Actions>
66516667 <ComposerPendingApprovalActions
66526668 requestId={activePendingApproval.requestId}
6669 disabled={!canOperateThread}
66536670 isResponding={respondingRequestIds.includes(
66546671 activePendingApproval.requestId,
66556672 )}
......@@ -6662,6 +6679,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66626679 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
66636680 <ComposerPendingUserInputPanel
66646681 pendingUserInputs={pendingUserInputs}
6682 disabled={!canOperateThread}
66656683 respondingRequestIds={
66666684 activePendingIsResponding && activePendingUserInput
66676685 ? [...respondingRequestIds, activePendingUserInput.requestId]
......@@ -6682,6 +6700,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
66826700 <div data-chat-composer-collapsed-controls="true">
66836701 <ComposerPendingUserInputPanel
66846702 pendingUserInputs={pendingUserInputs}
6703 disabled={!canOperateThread}
66856704 respondingRequestIds={
66866705 activePendingIsResponding && activePendingUserInput
66876706 ? [...respondingRequestIds, activePendingUserInput.requestId]
......@@ -6723,6 +6742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
67236742 {activePendingProgress?.activeQuestion?.multiSelect ? (
67246743 <ComposerPrimaryActions
67256744 compact
6745 canOperateThread={canOperateThread}
67266746 pendingAction={pendingPrimaryAction}
67276747 isRunning={false}
67286748 canInterrupt={false}
......@@ -7079,6 +7099,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
70797099 draftTarget: attachmentDraftTarget,
70807100 })
70817101 }
7102 disabled={!canOperateThread}
70827103 aria-label={`Retry upload for ${image.name}`}
70837104 />
70847105 }
......@@ -7178,6 +7199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
71787199 draftTarget: attachmentDraftTarget,
71797200 })
71807201 }
7202 disabled={!canOperateThread}
71817203 aria-label={`Retry upload for ${file.name}`}
71827204 />
71837205 }
......@@ -7256,6 +7278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
72567278 draftTarget: attachmentDraftTarget,
72577279 })
72587280 }
7281 disabled={!canOperateThread}
72597282 aria-label={`Retry upload for ${file.name}`}
72607283 />
72617284 }
......@@ -7408,6 +7431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
74087431 >
74097432 <ComposerPrimaryActions
74107433 compact
7434 canOperateThread={canOperateThread}
74117435 pendingAction={pendingPrimaryAction}
74127436 isRunning={false}
74137437 canInterrupt={false}
......@@ -7513,6 +7537,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
75137537 ) : null}
75147538 <ComposerFooterPrimaryActions
75157539 compact={isComposerResting || isComposerPrimaryActionsCompact}
7540 canOperateThread={canOperateThread}
75167541 activeContextWindow={
75177542 settings.contextWindowMeterEnabled ? activeContextWindow : null
75187543 }
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+14-5
......@@ -14,6 +14,7 @@ interface ComposerPendingApprovalActionsProps {
1414 requestId: RuntimeRequestId;
1515 isResponding: boolean;
1616 canRespond: boolean;
17 disabled?: boolean;
1718 options?: ReadonlyArray<ProviderApprovalOption> | undefined;
1819 onRespondToApproval: (
1920 requestId: RuntimeRequestId,
......@@ -32,6 +33,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
3233 requestId,
3334 isResponding,
3435 canRespond,
36 disabled = false,
3537 options = DEFAULT_APPROVAL_OPTIONS,
3638 onRespondToApproval,
3739}: ComposerPendingApprovalActionsProps) {
......@@ -50,9 +52,13 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
5052 key={option.decision}
5153 size="xs"
5254 variant={option.decision === "accept" ? "default" : "outline"}
53 disabled={isResponding || !canRespond}
55 disabled={disabled || isResponding || !canRespond}
5456 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 }}
5662 >
5763 {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}
5864 <span className="max-w-40 truncate">{option.label}</span>
......@@ -70,7 +76,7 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
7076 {moreOptions.length > 0 ? (
7177 <Menu>
7278 <MenuTrigger
73 disabled={isResponding}
79 disabled={disabled || isResponding}
7480 render={<Button size="icon-xs" variant="outline" aria-label="More approval options" />}
7581 >
7682 <EllipsisIcon />
......@@ -80,9 +86,12 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
8086 const item = (
8187 <MenuItem
8288 key={option.decision}
83 disabled={isResponding}
89 disabled={disabled || isResponding}
8490 aria-description={option.warning}
85 onClick={() => void onRespondToApproval(requestId, option.decision)}
91 onClick={() => {
92 if (!disabled && !isResponding)
93 void onRespondToApproval(requestId, option.decision);
94 }}
8695 variant="ghost"
8796 className="mb-1 last:mb-0"
8897 >
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";
1212
1313interface PendingUserInputPanelProps {
1414 pendingUserInputs: PendingUserInput[];
15 disabled?: boolean;
1516 respondingRequestIds: RuntimeRequestId[];
1617 answers: Record<string, PendingUserInputDraftAnswer>;
1718 questionIndex: number;
......@@ -22,6 +23,7 @@ interface PendingUserInputPanelProps {
2223
2324export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
2425 pendingUserInputs,
26 disabled = false,
2527 respondingRequestIds,
2628 answers,
2729 questionIndex,
......@@ -37,6 +39,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
3739 <ComposerPendingUserInputCard
3840 key={activePrompt.requestId}
3941 prompt={activePrompt}
42 disabled={disabled}
4043 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
4144 answers={answers}
4245 questionIndex={questionIndex}
......@@ -49,6 +52,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn
4952
5053const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
5154 prompt,
55 disabled,
5256 isResponding,
5357 answers,
5458 questionIndex,
......@@ -57,6 +61,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
5761 onDismiss,
5862}: {
5963 prompt: PendingUserInput;
64 disabled: boolean;
6065 isResponding: boolean;
6166 answers: Record<string, PendingUserInputDraftAnswer>;
6267 questionIndex: number;
......@@ -66,7 +71,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6671}) {
6772 // Message-mode requests remain answerable after their provider turn ends.
6873 const canRespond = prompt.responseCapability !== "not_resumable";
69 const responseDisabled = isResponding || !canRespond;
74 const responseDisabled = disabled || isResponding || !canRespond;
7075 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
7176 const activeQuestion = progress.activeQuestion;
7277 const autoAdvanceTimerRef = useRef<number | null>(null);
......@@ -88,6 +93,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
8893 onAdvanceRef.current = onAdvance;
8994 }, [onAdvance]);
9095
96 useEffect(() => {
97 if (disabled && autoAdvanceTimerRef.current !== null) {
98 window.clearTimeout(autoAdvanceTimerRef.current);
99 autoAdvanceTimerRef.current = null;
100 }
101 }, [disabled]);
102
91103 useEffect(() => {
92104 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {
93105 return;
......@@ -120,6 +132,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
120132
121133 const handleOptionSelection = useCallback(
122134 (questionId: string, optionValue: string) => {
135 if (disabled || isResponding) return;
123136 if (activeQuestion?.multiSelect) {
124137 onToggleOption(questionId, optionValue);
125138 return;
......@@ -134,7 +147,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
134147 onAdvanceRef.current();
135148 }, 200);
136149 },
137 [activeQuestion, onToggleOption],
150 [activeQuestion, disabled, isResponding, onToggleOption],
138151 );
139152
140153 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
......@@ -252,8 +265,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
252265 isSelected
253266 ? "bg-muted/55 text-foreground"
254267 : "bg-transparent text-foreground/85 hover:bg-muted/30",
255 isResponding && "opacity-50 cursor-not-allowed",
256 !isResponding && "cursor-pointer",
268 (disabled || isResponding) && "opacity-50 cursor-not-allowed",
269 !disabled && !isResponding && "cursor-pointer",
257270 );
258271 const content = (
259272 <>
......@@ -280,7 +293,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
280293 <button
281294 key={`${activeQuestion.id}:${optionValue}`}
282295 type="button"
283 disabled={isResponding}
296 disabled={disabled || isResponding}
284297 onClick={() => {
285298 handleOptionSelection(activeQuestion.id, optionValue);
286299 }}
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+2
......@@ -21,6 +21,7 @@ function renderPendingActions(isRunning: boolean) {
2121 return renderToStaticMarkup(
2222 createElement(ComposerPrimaryActions, {
2323 compact: true,
24 canOperateThread: true,
2425 pendingAction: {
2526 questionIndex: 0,
2627 isLastQuestion: true,
......@@ -49,6 +50,7 @@ function renderSendButton(sendDisabledReason: string | null = null) {
4950 return renderToStaticMarkup(
5051 createElement(ComposerPrimaryActions, {
5152 compact: true,
53 canOperateThread: true,
5254 pendingAction: null,
5355 isRunning: false,
5456 canInterrupt: false,
apps/web/src/components/chat/ComposerPrimaryActions.tsx+11-3
......@@ -26,6 +26,7 @@ interface PendingActionState {
2626
2727interface ComposerPrimaryActionsProps {
2828 compact: boolean;
29 canOperateThread: boolean;
2930 pendingAction: PendingActionState | null;
3031 /** The turn is running: sending steers or queues instead of starting a turn. */
3132 isRunning: boolean;
......@@ -80,6 +81,7 @@ const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
8081
8182export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
8283 compact,
84 canOperateThread,
8385 pendingAction,
8486 isRunning,
8587 canInterrupt,
......@@ -115,7 +117,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
115117 alternateModifier: shortcutModifiers.metaKey || shortcutModifiers.ctrlKey,
116118 }) === "queue";
117119 const alternateAction = alternateComposerDispatchAction(followUpBehavior);
118 const isSendDisabled = sendDisabledReason !== null;
120 const isSendDisabled = !canOperateThread || sendDisabledReason !== null;
119121 const stageBackdropVariant = useSidebarStageBackdropVariant(
120122 environmentIdentificationMode === "artwork",
121123 );
......@@ -131,7 +133,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
131133 insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8",
132134 )}
133135 {...pointerFocusProps}
134 onClick={onInterrupt}
136 disabled={!canOperateThread}
137 onClick={() => {
138 if (canOperateThread) onInterrupt();
139 }}
135140 aria-label="Stop generation"
136141 />
137142 }
......@@ -177,6 +182,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
177182 className={cn(messageActionPillClassName, "h-8 sm:h-7", compact ? "px-3" : "px-4")}
178183 {...pointerFocusProps}
179184 disabled={
185 !canOperateThread ||
180186 isEnvironmentUnavailable ||
181187 pendingAction.isResponding ||
182188 (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance)
......@@ -237,7 +243,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
237243 <MenuPopup align="end" side="top" {...composerFloatingLayerProps}>
238244 <MenuItem
239245 disabled={isSendBusy || isSendDisabled || isConnecting || isEnvironmentUnavailable}
240 onClick={() => void onImplementPlanInNewThread()}
246 onClick={() => {
247 if (canOperateThread) onImplementPlanInNewThread();
248 }}
241249 >
242250 Implement in a new thread
243251 </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 @@
11import { ThreadDetailsControl } from "./ThreadDetailsControl";
22import {
3 AuthOrchestrationOperateScope,
34 buildRemoteOpenUrl,
45 EditorId,
56 type EnvironmentId,
......@@ -66,6 +67,8 @@ import {
6667 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
6768 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
6869} from "./threadDetailsPanelStyles";
70import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
71import { useComposerMenuState } from "./useComposerMenuState";
6972
7073type OpenInOption = {
7174 label: string;
......@@ -223,6 +226,10 @@ export const OpenInPicker = memo(function OpenInPicker({
223226 const panelAnchorRef = useRef<HTMLDivElement | null>(null);
224227 const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor");
225228 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);
226233 const remoteCapableEditors = useRemoteCapableEditors();
227234 const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint();
228235 const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine";
......@@ -258,6 +265,7 @@ export const OpenInPicker = memo(function OpenInPicker({
258265 });
259266 return;
260267 }
268 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
261269 const result = openInEditorMutation({
262270 environmentId,
263271 input: {
......@@ -285,18 +293,32 @@ export const OpenInPicker = memo(function OpenInPicker({
285293 );
286294
287295 useEffect(() => {
288 if (!enableShortcut) return;
296 if (!enableShortcut || !canOpenEditor) return;
289297 const handler = (e: globalThis.KeyboardEvent) => {
290298 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
291299 if (!openInCwd) return;
292300 if (!preferredEditor) return;
301 if (
302 remote.mode === "local-exec" &&
303 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
304 )
305 return;
293306
294307 e.preventDefault();
295308 void openInEditor(preferredEditor);
296309 };
297310 window.addEventListener("keydown", handler);
298311 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 ]);
300322 const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open";
301323
302324 const editorItems = (
......@@ -315,6 +337,7 @@ export const OpenInPicker = memo(function OpenInPicker({
315337 {options.map(({ label, Icon, value, kind }) => (
316338 <MenuItem
317339 density={presentation === "menu" ? "touch" : "default"}
340 disabled={!openInCwd || !canOpenEditor}
318341 key={value}
319342 onClick={() => openInEditor(value)}
320343 >
......@@ -341,7 +364,7 @@ export const OpenInPicker = memo(function OpenInPicker({
341364 <MenuItem
342365 density={presentation === "menu" ? "touch" : "default"}
343366
344 disabled={!openInCwd || remote.mode === "remote-unavailable"}
367 disabled={!openInCwd || !canOpenEditor}
345368 onClick={() => openInEditor(preferredEditor)}
346369 >
347370 <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />
......@@ -352,7 +375,7 @@ export const OpenInPicker = memo(function OpenInPicker({
352375 </MenuItem>
353376 )}
354377 <MenuSub>
355 <MenuSubTrigger density="touch">
378 <MenuSubTrigger density="touch" disabled={isHostEditorDenied}>
356379 <SquareArrowOutUpRightIcon className="size-4" />
357380 <MenuItemLabel>Open in…</MenuItemLabel>
358381 </MenuSubTrigger>
......@@ -376,7 +399,7 @@ export const OpenInPicker = memo(function OpenInPicker({
376399 variant={isPanel ? "ghost" : "outline"}
377400 part="primary"
378401 panel={isPanel}
379 disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"}
402 disabled={!preferredEditor || !openInCwd || !canOpenEditor}
380403 onClick={() => openInEditor(preferredEditor)}
381404 >
382405 {primaryOption?.Icon ? (
......@@ -409,8 +432,9 @@ export const OpenInPicker = memo(function OpenInPicker({
409432 ) : (
410433 <GroupSeparator {...(!compact ? { className: "hidden @3xl/header-actions:block" } : {})} />
411434 )}
412 <Menu>
435 <Menu open={menuOpen} onOpenChange={setMenuOpen}>
413436 <MenuTrigger
437 disabled={isHostEditorDenied}
414438 render={
415439 <ThreadDetailsControl
416440 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 @@
1import { useAtomValue } from "@effect/atom-react";
12import { ThreadDetailsControl } from "./ThreadDetailsControl";
23import { useNavigate } from "@tanstack/react-router";
34import { CalendarClockIcon, PencilIcon, PlayIcon, Settings2Icon } from "lucide-react";
45import { 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";
612import {
713 isAtomCommandInterrupted,
814 squashAtomCommandFailure,
......@@ -15,6 +21,7 @@ import { useEnvironmentQuery } from "../../state/query";
1521import { serverEnvironment } from "../../state/server";
1622import { useAtomCommand } from "../../state/use-atom-command";
1723
24import { readEnvironmentScope } from "../../state/session";
1825import { Switch } from "../ui/switch";
1926import { stackedThreadToast, toastManager } from "../ui/toast";
2027import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -40,6 +47,9 @@ export function ThreadAutomationsPanel(props: {
4047 readonly environmentId: EnvironmentId;
4148 readonly threadId: ThreadId;
4249}) {
50 const canOperate = useAtomValue(
51 serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId),
52 );
4353 const tasksQuery = useEnvironmentQuery(
4454 serverEnvironment.scheduledTasksLive({ environmentId: props.environmentId, input: {} }),
4555 );
......@@ -71,7 +81,11 @@ export function ThreadAutomationsPanel(props: {
7181 };
7282
7383 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;
7589 setBusyTaskId(task.id);
7690 // Partial update: only the enabled flag changes, so a toggle can never
7791 // revert concurrent edits made to the task elsewhere.
......@@ -86,7 +100,11 @@ export function ThreadAutomationsPanel(props: {
86100 };
87101
88102 const runNow = async (task: ScheduledTask) => {
89 if (busyTaskId !== null) return;
103 if (
104 busyTaskId !== null ||
105 !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)
106 )
107 return;
90108 setBusyTaskId(task.id);
91109 const result = await runTaskNow({
92110 environmentId: props.environmentId,
......@@ -196,7 +214,9 @@ export function ThreadAutomationsPanel(props: {
196214 variant="ghost"
197215 part="icon"
198216 aria-label={`Run ${task.title} now`}
199 disabled={busyTaskId !== null || task.lastRunStatus === "running"}
217 disabled={
218 !canOperate || busyTaskId !== null || task.lastRunStatus === "running"
219 }
200220 onClick={() => void runNow(task)}
201221 >
202222 <PlayIcon className="size-3.5" />
......@@ -208,7 +228,7 @@ export function ThreadAutomationsPanel(props: {
208228 )}
209229 <Switch
210230 checked={task.enabled}
211 disabled={busyTaskId !== null}
231 disabled={!canOperate || busyTaskId !== null}
212232 aria-label={task.enabled ? `Pause ${task.title}` : `Resume ${task.title}`}
213233 onCheckedChange={(enabled) => void toggleEnabled(task, enabled)}
214234 />
apps/web/src/components/chat/ThreadDetailsPanel.tsx+2-2
......@@ -170,8 +170,8 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
170170
171171 {density !== "essential" && props.showOpenInPicker ? (
172172 <OpenInPicker
173 environmentId={props.environmentId}
174173 keybindings={props.keybindings}
174 environmentId={props.environmentId}
175175 availableEditors={props.availableEditors}
176176 openInCwd={props.gitCwd}
177177 displayMode="panel"
......@@ -180,10 +180,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) {
180180
181181 {props.activeProjectScripts ? (
182182 <ProjectScriptsControl
183 environmentId={props.environmentId}
183184 displayMode="panel"
184185 scripts={props.activeProjectScripts}
185186 fileScripts={fileScripts}
186 keybindings={props.keybindings}
187187 preferredScriptId={props.preferredScriptId}
188188 onRunScript={props.onRunProjectScript}
189189 onAddScript={props.onAddProjectScript}
apps/web/src/components/chat/TraitsPicker.tsx+5-2
......@@ -581,14 +581,16 @@ export const TraitsPicker = memo(function TraitsPicker({
581581 isComposerOwned,
582582 size = "sm",
583583 hidden = false,
584 disabled = false,
584585 ...persistence
585586}: TraitsMenuContentProps &
586587 TraitsPersistence & {
587588 size?: ComposerControlSize;
588589 hidden?: boolean;
590 disabled?: boolean;
589591 }) {
590592 const composerFloatingLayerProps = useComposerMenuProps();
591 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden);
593 const [isMenuOpen, setIsMenuOpen] = useComposerMenuState(hidden || disabled);
592594 const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } =
593595 getTraitsSectionVisibility({
594596 provider,
......@@ -627,7 +629,7 @@ export const TraitsPicker = memo(function TraitsPicker({
627629 <Menu
628630 open={isMenuOpen}
629631 onOpenChange={(open) => {
630 setIsMenuOpen(open);
632 setIsMenuOpen(open && !disabled);
631633 }}
632634 >
633635 <Tooltip>
......@@ -636,6 +638,7 @@ export const TraitsPicker = memo(function TraitsPicker({
636638 <MenuTrigger
637639 render={
638640 <ComposerControl
641 disabled={disabled}
639642 aria-label={triggerLabel}
640643 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
641644 size={size}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+69-19
......@@ -1,5 +1,12 @@
11import { 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";
310import { useEffect, useRef, useState } from "react";
411
512import {
......@@ -9,9 +16,13 @@ import {
916} from "~/cloud/connectOnboarding";
1017import { hasCloudPublicConfig } from "~/cloud/publicConfig";
1118import { useCloudLinkController } from "~/cloud/useCloudLinkController";
12import { usePrimarySessionState } from "~/environments/primary";
1319import { 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";
1526import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
1627import { Button } from "../ui/button";
1728import { Checkbox } from "../ui/checkbox";
......@@ -37,6 +48,8 @@ export function ConnectOnboardingDialog() {
3748
3849type OnboardingStep = "publish" | "devices";
3950
51const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());
52
4053function ConfiguredConnectOnboardingDialog() {
4154 // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read as
4255 // signed-out, or its later activation would look like a fresh sign-in.
......@@ -47,22 +60,22 @@ function ConfiguredConnectOnboardingDialog() {
4760 ConnectOnboardingOptOutSchema,
4861 );
4962
50 const desktopBridge = window.desktopBridge;
51 const primarySessionState = usePrimarySessionState();
52 const currentSessionScopes = desktopBridge
53 ? AuthAdministrativeScopes
54 : primarySessionState.data?.authenticated
55 ? (primarySessionState.data.scopes ?? null)
56 : null;
57 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
63 const { isReady: environmentsReady } = useEnvironments();
64 const primaryEnvironmentId = usePrimaryEnvironmentId();
65 const primarySessionState = useAtomValue(
66 primaryEnvironmentId === null
67 ? EMPTY_SESSION_STATE_ATOM
68 : environmentSession.sessionStateAtom(primaryEnvironmentId),
69 );
70 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
71 const canWriteRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
72 const canManageRelay = canReadRelay && canWriteRelay;
5873 // The publish step is only offered when we know the answer; opening the
5974 // wizard before the session state resolves would let the step set change
6075 // mid-flight. A failed session read still opens the wizard — it just means
6176 // no publish step.
6277 const sessionScopesKnown =
63 Boolean(desktopBridge) ||
64 primarySessionState.data !== null ||
65 primarySessionState.error !== null;
78 primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial";
6679
6780 const controller = useCloudLinkController();
6881 const showPublishStep = canManageRelay && controller.linkState.target !== null;
......@@ -80,6 +93,12 @@ function ConfiguredConnectOnboardingDialog() {
8093 const prefilledFromLinkStateRef = useRef(false);
8194 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
83102 const optOutAccounts = optOutState.optOutAccounts;
84103
85104 // Every sign-in or account switch that completes during this session
......@@ -176,6 +195,16 @@ function ConfiguredConnectOnboardingDialog() {
176195 };
177196
178197 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;
179208 // The wizard only ever enables — with both toggles off there is nothing to
180209 // apply, and an existing link must not be torn down from onboarding.
181210 if (!exposeEnvironment && !publishAgentActivity) {
......@@ -225,6 +254,13 @@ function ConfiguredConnectOnboardingDialog() {
225254 isStepDisabled={() => isApplying}
226255 onStepChange={(index) => {
227256 const next = steps[index];
257 if (
258 next === "publish" &&
259 (primaryEnvironmentId === null ||
260 !readEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope) ||
261 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope))
262 )
263 return;
228264 if (next) setStep(next);
229265 }}
230266 />
......@@ -235,10 +271,24 @@ function ConfiguredConnectOnboardingDialog() {
235271 <PublishStep
236272 exposeEnvironment={exposeEnvironment}
237273 publishAgentActivity={publishAgentActivity}
238 disabled={isApplying}
239 operationError={controller.operationError}
240 onExposeEnvironmentChange={setExposeEnvironment}
241 onPublishAgentActivityChange={setPublishAgentActivity}
274 disabled={isApplying || !canManageRelay || linkStateData === null}
275 operationError={controller.operationError ?? controller.linkState.error}
276 onExposeEnvironmentChange={(enabled) => {
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 }}
242292 />
243293 ) : (
244294 <DevicesStep />
......@@ -261,7 +311,7 @@ function ConfiguredConnectOnboardingDialog() {
261311 Not now
262312 </Button>
263313 <Button
264 disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}
314 disabled={isApplying || !canManageRelay || linkStateData === null}
265315 onClick={() => void applyPublishSelection()}
266316 >
267317 {isApplying ? "Enabling…" : "Continue"}
apps/web/src/components/device/DeviceSetup.tsx+7-3
......@@ -2,6 +2,7 @@ import { DeviceHostUpdates } from "./DeviceHostUpdates";
22import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
33import { Check } from "lucide-react";
44import { Check as CheckGlyph, CircleAlert } from "lucide";
5import { AuthSettingsWriteScope } from "@t3tools/contracts";
56import { useState } from "react";
67
78import { Button } from "~/components/ui/button";
......@@ -12,6 +13,7 @@ import { Spinner } from "~/components/ui/spinner";
1213import { Switch } from "~/components/ui/switch";
1314import { deviceEnvironment } from "~/state/device";
1415import { useAtomCommand } from "~/state/use-atom-command";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
1517import { cn } from "~/lib/utils";
1618
1719const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android");
......@@ -57,6 +59,7 @@ export function DeviceSetup(props: {
5759 readonly state: DeviceServiceState;
5860 readonly onComplete?: () => void;
5961}) {
62 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
6063 const configure = useAtomCommand(deviceEnvironment.configure);
6164 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
6265 const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null);
......@@ -71,6 +74,7 @@ export function DeviceSetup(props: {
7174 kind: NonNullable<typeof pending>,
7275 input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean },
7376 ) => {
77 if (!readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) return;
7478 setPending(kind);
7579 try {
7680 const result = await configure({ environmentId: props.environmentId, input });
......@@ -103,7 +107,7 @@ export function DeviceSetup(props: {
103107 <p className="text-muted-foreground">{deviceHubDescription}</p>
104108 <Switch
105109 checked={enabled}
106 disabled={busy || pending !== null}
110 disabled={!canConfigure || busy || pending !== null}
107111 aria-label="Enable device hub"
108112 onCheckedChange={(checked) =>
109113 void update("hub", {
......@@ -144,7 +148,7 @@ export function DeviceSetup(props: {
144148 <p className="text-muted-foreground">{agentDeviceDescription}</p>
145149 <Switch
146150 checked={props.state.agentAccessEnabled}
147 disabled={!enabled || busy || pending !== null}
151 disabled={!canConfigure || !enabled || busy || pending !== null}
148152 aria-label="Allow agents to control devices"
149153 onCheckedChange={(checked) =>
150154 void update("agent", { agentAccessEnabled: Boolean(checked) })
......@@ -185,7 +189,7 @@ export function DeviceSetup(props: {
185189 </Button>
186190 ) : (
187191 <Button
188 disabled={props.state.hostStatus !== "ready" || pending !== null}
192 disabled={!canConfigure || props.state.hostStatus !== "ready" || pending !== null}
189193 onClick={() => void update("complete", { onboardingCompleted: true })}
190194 >
191195 {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(() => ({
1212 toast: vi.fn(),
1313 projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,
1414}));
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}));
1521vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
1622vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
1723vi.mock("../../state/use-atom-command", () => ({
apps/web/src/components/onboarding/WelcomeWizard.tsx+39-1
......@@ -17,6 +17,7 @@ import {
1717import {
1818 CommandId,
1919 defaultInstanceIdForDriver,
20 AuthOrchestrationOperateScope,
2021 ProviderDriverKind,
2122 ThreadId,
2223} from "@t3tools/contracts";
......@@ -60,6 +61,7 @@ import { isOnboardingRelayEnvironment } from "../../onboarding/targetEnvironment
6061import { useProjectScans } from "../../onboarding/useProjectScans";
6162import { projectEnvironment } from "../../state/projects";
6263import { serverEnvironment } from "../../state/server";
64import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
6365import { terminalEnvironment } from "../../state/terminal";
6466import { useAtomCommand } from "../../state/use-atom-command";
6567import { connectPairing } from "../../connection/onboarding";
......@@ -1145,6 +1147,8 @@ function AgentInstallTerminal({
11451147
11461148// ── Step 4: import ───────────────────────────────────────────
11471149
1150const IMPORT_PERMISSION_MESSAGE = "This connection cannot import projects or thread history.";
1151
11481152function ImportStep({
11491153 scans,
11501154 isImporting,
......@@ -1161,6 +1165,7 @@ function ImportStep({
11611165 ) => Promise<boolean>;
11621166}) {
11631167 const { environments } = useEnvironments();
1168 const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope);
11641169 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
11651170 const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
11661171 const projects = useProjects();
......@@ -1222,6 +1227,16 @@ function ImportStep({
12221227 );
12231228 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
12251240 const finishAfterImport = () => {
12261241 const projectRef = resolveOnboardingLandingProject(
12271242 lastImportSelectionRef.current,
......@@ -1238,6 +1253,18 @@ function ImportStep({
12381253
12391254 const runImport = async (selection: typeof candidates) => {
12401255 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 }
12411268 if (selection.length === 0) {
12421269 void onDone();
12431270 return;
......@@ -1269,6 +1296,10 @@ function ImportStep({
12691296 ) {
12701297 return;
12711298 }
1299 if (!hasAccess()) {
1300 stopForDeniedAccess();
1301 return;
1302 }
12721303 if (importedProjects.has(candidate.key)) continue;
12731304 let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate);
12741305 if (projectId === null) {
......@@ -1308,6 +1339,10 @@ function ImportStep({
13081339 }
13091340 }
13101341
1342 if (!hasAccess()) {
1343 stopForDeniedAccess();
1344 return;
1345 }
13111346 const threadImportResult = await importThreads({
13121347 environmentId,
13131348 input: { projectId, expectedWorkspaceRoot: candidate.path },
......@@ -1457,13 +1492,16 @@ function ImportStep({
14571492 })}
14581493 </div>
14591494 </ScrollArea>
1495 {visibleImportError ? (
1496 <p className="mt-3 text-sm text-destructive">{visibleImportError}</p>
1497 ) : null}
14601498 <div className="mt-6 flex flex-wrap items-center justify-end gap-3">
14611499 <Button variant="ghost-muted" disabled={isImporting} onClick={finishAfterImport}>
14621500 Do not import projects
14631501 </Button>
14641502 <Button
14651503 autoFocus
1466 disabled={isImporting || selected.length === 0}
1504 disabled={!canImport || isImporting || selected.length === 0}
14671505 onClick={() => void runImport(selected)}
14681506 >
14691507 {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 @@
1import { EnvironmentId } from "@t3tools/contracts";
12import * as Cause from "effect/Cause";
23import { AsyncResult } from "effect/reactivity";
34import { act, StrictMode, type ReactNode } from "react";
45import { create, type ReactTestRenderer } from "react-test-renderer";
56import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
67
8vi.mock("~/state/session", () => ({
9 readEnvironmentScope: () => true,
10 useEnvironmentScope: () => true,
11}));
712vi.mock("./ui/dialog", () => ({
813 Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null),
914 DialogDescription: "p",
......@@ -58,6 +63,7 @@ function editor(nextRequest: ProjectScriptEditorRequest) {
5863 return (
5964 <StrictMode>
6065 <ProjectScriptEditorDialog
66 environmentId={EnvironmentId.make("test")}
6167 request={nextRequest}
6268 scripts={[]}
6369 onSubmit={onSubmit}
apps/web/src/components/projectScriptEditor.tsx+46-14
......@@ -1,7 +1,10 @@
1import type {
2 ProjectScript,
3 ProjectScriptIcon,
4 ResolvedKeybindingsConfig,
1import {
2 AuthOrchestrationOperateScope,
3 AuthSettingsWriteScope,
4 type EnvironmentId,
5 type ProjectScript,
6 type ProjectScriptIcon,
7 type ResolvedKeybindingsConfig,
58} from "@t3tools/contracts";
69import {
710 isAtomCommandInterrupted,
......@@ -31,6 +34,8 @@ import {
3134} from "~/lib/projectScriptKeybindings";
3235import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
3336import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
37import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
38import { useComposerMenuState } from "./chat/useComposerMenuState";
3439import {
3540 AlertDialog,
3641 AlertDialogClose,
......@@ -87,7 +92,8 @@ export interface NewProjectScriptInput {
8792 runOnWorktreeCreate: boolean;
8893 /** Setup scripts only: hold the agent until the script exits. */
8994 waitForSetup: boolean;
90 keybinding: string | null;
95 /** Omit to preserve the current shortcut when the form did not edit it. */
96 keybinding?: string | null;
9197 /** Optional URL to open in the in-app preview when this script runs. */
9298 previewUrl: string | null;
9399 /** When true, automatically open the preview panel pointed at `previewUrl`. */
......@@ -140,12 +146,16 @@ export function editorRequestForScript(
140146 * being edited via `request`; the dialog owns the form state and validation.
141147 */
142148export function ProjectScriptEditorDialog({
149 environmentId,
150 editScope = AuthOrchestrationOperateScope,
143151 request,
144152 scripts,
145153 onSubmit,
146154 onDelete,
147155 onClose,
148156}: {
157 environmentId: EnvironmentId;
158 editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
149159 request: ProjectScriptEditorRequest | null;
150160 /** Existing scripts, used to derive a unique id for new scripts. */
151161 scripts: ReadonlyArray<ProjectScript>;
......@@ -156,18 +166,20 @@ export function ProjectScriptEditorDialog({
156166 onDelete: (scriptId: string) => void;
157167 onClose: () => void;
158168}) {
169 const canEditActions = useEnvironmentScope(environmentId, editScope);
170 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
159171 const formId = React.useId();
160172 const [name, setName] = useState("");
161173 const [command, setCommand] = useState("");
162174 const [icon, setIcon] = useState<ProjectScriptIcon>("play");
163 const [iconPickerOpen, setIconPickerOpen] = useState(false);
175 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
164176 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
165177 const [waitForSetup, setWaitForSetup] = useState(false);
166178 const [keybinding, setKeybinding] = useState("");
167179 const [previewUrl, setPreviewUrl] = useState("");
168180 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
169181 const [validationError, setValidationError] = useState<string | null>(null);
170 const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
182 const [deleteConfirmOpen, setDeleteConfirmOpen] = useComposerMenuState(!canEditActions);
171183 const [savingRequest, setSavingRequest] = useState<ProjectScriptEditorRequest | null>(null);
172184 const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
173185
......@@ -199,7 +211,7 @@ export function ProjectScriptEditorDialog({
199211 setAutoOpenPreview(request.initial.autoOpenPreview);
200212 setValidationError(request.error ?? null);
201213 setSavingRequest(null);
202 }, [request]);
214 }, [request, setIconPickerOpen]);
203215
204216 const close = () => {
205217 pendingSubmissionRef.current = null;
......@@ -209,6 +221,8 @@ export function ProjectScriptEditorDialog({
209221 };
210222
211223 const captureKeybinding = (event: KeyboardEvent<HTMLInputElement>) => {
224 if (!readEnvironmentScope(environmentId, editScope)) return;
225 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
212226 if (event.key === "Tab") return;
213227 event.preventDefault();
214228 if (event.key === "Backspace" || event.key === "Delete") {
......@@ -223,6 +237,17 @@ export function ProjectScriptEditorDialog({
223237 const submit = async (event: FormEvent) => {
224238 event.preventDefault();
225239 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 }
226251 const trimmedName = name.trim();
227252 const trimmedCommand = command.trim();
228253 if (trimmedName.length === 0) {
......@@ -254,7 +279,9 @@ export function ProjectScriptEditorDialog({
254279 icon,
255280 runOnWorktreeCreate,
256281 waitForSetup: runOnWorktreeCreate && waitForSetup,
257 keybinding: keybindingRule?.key ?? null,
282 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding
283 ? { keybinding: keybindingRule?.key ?? null }
284 : {}),
258285 previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null,
259286 autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false,
260287 } satisfies NewProjectScriptInput;
......@@ -309,7 +336,7 @@ export function ProjectScriptEditorDialog({
309336 </DialogHeader>
310337 <DialogPanel>
311338 <form id={formId} onSubmit={submit}>
312 <fieldset className="space-y-4" disabled={isSaving}>
339 <fieldset className="space-y-4" disabled={isSaving || !canEditActions}>
313340 <div className="space-y-1.5">
314341 <Label htmlFor="script-name">Name</Label>
315342 <div className="flex items-center gap-2">
......@@ -364,6 +391,7 @@ export function ProjectScriptEditorDialog({
364391 <div className="space-y-1.5">
365392 <Label htmlFor="script-keybinding">Keybinding</Label>
366393 <Input
394 disabled={!canWriteSettings}
367395 id="script-keybinding"
368396 placeholder="Press shortcut"
369397 value={keybinding}
......@@ -437,7 +465,7 @@ export function ProjectScriptEditorDialog({
437465 type="button"
438466 variant="destructive-outline"
439467 className="mr-auto"
440 disabled={isSaving}
468 disabled={isSaving || !canEditActions}
441469 onClick={() => setDeleteConfirmOpen(true)}
442470 >
443471 Delete
......@@ -446,14 +474,14 @@ export function ProjectScriptEditorDialog({
446474 <Button type="button" variant="outline" onClick={close}>
447475 Cancel
448476 </Button>
449 <Button form={formId} type="submit" disabled={isSaving}>
477 <Button form={formId} type="submit" disabled={isSaving || !canEditActions}>
450478 {isSaving ? "Saving…" : isEditing ? "Save changes" : "Save action"}
451479 </Button>
452480 </DialogFooter>
453481 </DialogPopup>
454482 </Dialog>
455483
456 <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
484 <AlertDialog open={deleteConfirmOpen && canEditActions} onOpenChange={setDeleteConfirmOpen}>
457485 <AlertDialogPopup>
458486 <AlertDialogHeader>
459487 <AlertDialogTitle>Delete action "{name}"?</AlertDialogTitle>
......@@ -463,9 +491,13 @@ export function ProjectScriptEditorDialog({
463491 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
464492 <Button
465493 variant="destructive"
466 disabled={isSaving}
494 disabled={isSaving || !canEditActions}
467495 onClick={() => {
468496 if (!request?.scriptId) return;
497 if (!readEnvironmentScope(environmentId, editScope)) {
498 setValidationError("This connection cannot change project actions.");
499 return;
500 }
469501 setDeleteConfirmOpen(false);
470502 close();
471503 onDelete(request.scriptId);
apps/web/src/components/settings/AcpRegistrySearchStep.test.tsx+17
......@@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({
1414 result: null as { readonly agents: ReadonlyArray<AcpRegistrySearchAgent> } | null,
1515 error: null as string | null,
1616 isPending: false,
17 canManage: true,
1718 refresh: vi.fn(),
1819 search: vi.fn(() => atoms.search),
1920 prepare: vi.fn(),
......@@ -53,6 +54,11 @@ vi.mock("../../state/server", () => ({
5354 },
5455}));
5556
57vi.mock("../../state/session", () => ({
58 useEnvironmentScope: () => state.canManage,
59 readEnvironmentScope: () => state.canManage,
60}));
61
5662vi.mock("../../state/query", () => ({
5763 useEnvironmentQuery: () => ({
5864 data: state.result,
......@@ -119,6 +125,7 @@ function findByAriaLabel(
119125
120126describe("AcpRegistrySearchStep", () => {
121127 beforeEach(() => {
128 state.canManage = true;
122129 vi.useFakeTimers();
123130 hooks.reset();
124131 state.result = null;
......@@ -265,6 +272,16 @@ describe("AcpRegistrySearchStep", () => {
265272 });
266273 });
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
268285 it("ignores a stale prepare completion", async () => {
269286 let resolveFirst!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
270287 let resolveSecond!: (value: { readonly _tag: "Success"; readonly value: unknown }) => void;
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+5-1
......@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
13import {
24 isAtomCommandInterrupted,
35 squashAtomCommandFailure,
......@@ -64,6 +66,7 @@ export function AcpRegistrySearchStep({
6466 // An empty registry query is the compact compatible catalog. Start there so
6567 // entering this step is useful before the user knows what to search for.
6668 const [submittedQuery, setSubmittedQuery] = useState("");
69 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
6770 const [preparingId, setPreparingId] = useState<string | null>(null);
6871 const [prepareError, setPrepareError] = useState<string | null>(null);
6972 const prepareGeneration = useRef(0);
......@@ -105,6 +108,7 @@ export function AcpRegistrySearchStep({
105108 };
106109
107110 const handlePrepare = async (agent: AcpRegistrySearchAgent) => {
111 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
108112 const generation = ++prepareGeneration.current;
109113 setPrepareError(null);
110114 setPreparingId(agent.id);
......@@ -271,7 +275,7 @@ export function AcpRegistrySearchStep({
271275 ) : null}
272276 <Button
273277 aria-label={`${alreadyAdded ? "Already added" : isPreparing ? progressLabel : "Add"} ${agent.name}`}
274 disabled={alreadyAdded || preparingId !== null}
278 disabled={!canManageProviders || alreadyAdded || preparingId !== null}
275279 onClick={() => void handlePrepare(agent)}
276280 size="xs"
277281 variant={isPreparing ? "secondary" : "outline"}
apps/web/src/components/settings/AcpSessionManagementSection.test.tsx+86-49
......@@ -32,6 +32,12 @@ const commands = vi.hoisted(() => ({
3232 logout: vi.fn(),
3333}));
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}));
3541const dialogs = vi.hoisted(() => ({ confirm: vi.fn() }));
3642
3743vi.mock("react", async (importOriginal) => {
......@@ -105,14 +111,14 @@ const provider = {
105111 skills: [],
106112} satisfies ServerProvider;
107113
108function render(): ReactElement<Record<string, unknown>> {
114function render(readOnly = false): ReactElement<Record<string, unknown>> {
109115 hooks.beginRender();
110116 return AcpSessionManagementSection({
111117 environmentId,
112118 instanceId,
113119 provider,
114120 projects: [{ id: projectId, title: "Antigravity", workspaceRoot: session.cwd }],
115 readOnly: false,
121 readOnly,
116122 }) as ReactElement<Record<string, unknown>>;
117123}
118124
......@@ -139,55 +145,56 @@ async function flushPromises(): Promise<void> {
139145 await Promise.resolve();
140146}
141147
142describe("AcpSessionManagementSection", () => {
143 beforeEach(() => {
144 hooks.reset();
145 commands.list.mockReset().mockResolvedValue({
146 _tag: "Success",
147 value: {
148 sessions: [session],
149 nextCursor: null,
150 canLoad: true,
151 canResume: true,
152 canDelete: true,
153 },
154 });
155 commands.import.mockReset().mockResolvedValue({
156 _tag: "Success",
157 value: { threadId: ThreadId.make("thread-imported"), imported: true },
158 });
159 commands.delete.mockReset().mockResolvedValue({
160 _tag: "Success",
161 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);
148beforeEach(() => {
149 hooks.reset();
150 grants.scopes = new Set(["orchestration:read", "orchestration:operate", "providers:manage"]);
151 commands.list.mockReset().mockResolvedValue({
152 _tag: "Success",
153 value: {
154 sessions: [session],
155 nextCursor: null,
156 canLoad: true,
157 canResume: true,
158 canDelete: true,
159 },
160 });
161 commands.import.mockReset().mockResolvedValue({
162 _tag: "Success",
163 value: { threadId: ThreadId.make("thread-imported"), imported: true },
164 });
165 commands.delete.mockReset().mockResolvedValue({
166 _tag: "Success",
167 value: { deleted: true },
189168 });
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", () => {
191198 it("lists and imports native sessions through the owning environment", async () => {
192199 const initial = render();
193200 (findByLabel(initial, "List sessions").props.onClick as (() => void) | undefined)?.();
......@@ -292,3 +299,33 @@ describe("AcpSessionManagementSection", () => {
292299 });
293300 });
294301});
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 @@
1import {
2 AuthOrchestrationOperateScope,
3 AuthProvidersManageScope,
4 AuthOrchestrationReadScope,
5} from "@t3tools/contracts";
6import { useEnvironmentScope, readEnvironmentScope } from "~/state/session";
17import type {
28 AcpRegistryConfigurableProvider,
39 AcpRegistrySession,
......@@ -45,6 +51,8 @@ export function AcpSessionManagementSection(props: {
4551 readonly projects: ReadonlyArray<AcpSessionProject>;
4652 readonly readOnly: boolean;
4753}) {
54 const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
55 const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope);
4856 const [projectId, setProjectId] = useState<ProjectId | null>(props.projects[0]?.id ?? null);
4957 const [sessions, setSessions] = useState<ReadonlyArray<AcpRegistrySession>>([]);
5058 const [nextCursor, setNextCursor] = useState<string | null>(null);
......@@ -117,7 +125,12 @@ export function AcpSessionManagementSection(props: {
117125 };
118126
119127 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;
121134 setImportingSessionId(session.sessionId);
122135 const result = await importSession({
123136 environmentId: props.environmentId,
......@@ -154,7 +167,8 @@ export function AcpSessionManagementSection(props: {
154167 `Permanently delete native ACP session "${session.title ?? session.sessionId}"?`,
155168 { variant: "destructive" },
156169 );
157 if (!confirmed) return;
170 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope))
171 return;
158172 setDeletingSessionId(session.sessionId);
159173 const result = await deleteSession({
160174 environmentId: props.environmentId,
......@@ -199,7 +213,12 @@ export function AcpSessionManagementSection(props: {
199213 };
200214
201215 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;
203222 const draft = providerDrafts[provider.providerId];
204223 if (draft === undefined || draft.apiType.length === 0 || draft.baseUrl.length === 0) return;
205224 let headers: Record<string, string> | undefined;
......@@ -251,7 +270,7 @@ export function AcpSessionManagementSection(props: {
251270 `Disable ACP provider "${provider.providerId}"?`,
252271 { variant: "destructive" },
253272 );
254 if (!confirmed) return;
273 if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
255274 setSavingProviderId(provider.providerId);
256275 const result = await disableProvider({
257276 environmentId: props.environmentId,
......@@ -267,7 +286,7 @@ export function AcpSessionManagementSection(props: {
267286 };
268287
269288 const logoutProvider = async () => {
270 if (loggingOut) return;
289 if (loggingOut || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
271290 setLoggingOut(true);
272291 const result = await logout({
273292 environmentId: props.environmentId,
......@@ -310,7 +329,7 @@ export function AcpSessionManagementSection(props: {
310329 <>
311330 <Select
312331 value={projectId ?? ""}
313 disabled={props.readOnly || projectOperationPending}
332 disabled={!canRead || projectOperationPending}
314333 onValueChange={(value) => {
315334 if (value === null) return;
316335 setProjectId(value as ProjectId);
......@@ -337,7 +356,7 @@ export function AcpSessionManagementSection(props: {
337356 type="button"
338357 size="sm"
339358 variant="outline"
340 disabled={props.readOnly || loading || projectId === null}
359 disabled={!canRead || loading || projectId === null}
341360 onClick={() => void loadSessions()}
342361 >
343362 {loading ? "Loading" : sessions.length === 0 ? "List sessions" : "Refresh"}
......@@ -370,7 +389,7 @@ export function AcpSessionManagementSection(props: {
370389 size="xs"
371390 variant="ghost-muted"
372391 disabled={
373 props.readOnly ||
392 !canOperate ||
374393 !canImport ||
375394 session.importedThreadId !== null ||
376395 importingSessionId !== null
......@@ -389,7 +408,7 @@ export function AcpSessionManagementSection(props: {
389408 size="xs"
390409 variant="ghost-muted"
391410 disabled={
392 props.readOnly ||
411 !canOperate ||
393412 session.importedThreadId !== null ||
394413 deletingSessionId !== null
395414 }
......@@ -410,7 +429,7 @@ export function AcpSessionManagementSection(props: {
410429 size="xs"
411430 variant="ghost-muted"
412431 className="w-fit"
413 disabled={props.readOnly || loading}
432 disabled={!canRead || loading}
414433 onClick={() => void loadSessions(nextCursor)}
415434 >
416435 {loading ? "Loading" : "Load more"}
......@@ -433,7 +452,7 @@ export function AcpSessionManagementSection(props: {
433452 type="button"
434453 size="xs"
435454 variant="outline"
436 disabled={props.readOnly || loadingProviders || projectId === null}
455 disabled={!canRead || loadingProviders || projectId === null}
437456 onClick={() => void loadProviders()}
438457 >
439458 {loadingProviders ? "Loading" : providers.length === 0 ? "List providers" : "Refresh"}
......@@ -443,7 +462,7 @@ export function AcpSessionManagementSection(props: {
443462 {!canList && props.projects.length > 1 ? (
444463 <Select
445464 value={projectId ?? ""}
446 disabled={props.readOnly || projectOperationPending}
465 disabled={!canRead || projectOperationPending}
447466 onValueChange={(value) => {
448467 if (value === null) return;
449468 setProjectId(value as ProjectId);
apps/web/src/components/settings/AddCodexAccountDialog.tsx+10-2
......@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
13import { useAtomValue } from "@effect/atom-react";
24import {
35 ProviderDriverKind,
......@@ -31,6 +33,7 @@ export function AddCodexAccountDialog({
3133 | undefined;
3234 readonly renderSetup: (instanceId: ProviderInstanceId, provider: ServerProvider) => ReactNode;
3335}) {
36 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
3437 const settings = useEnvironmentSettings(environmentId);
3538 const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId));
3639 const update = useAtomCommand(serverEnvironment.updateSettings, "Add ChatGPT account");
......@@ -47,7 +50,8 @@ export function AddCodexAccountDialog({
4750 }, [connected]);
4851
4952 const createAccount = async () => {
50 if (pending || !name.trim()) return;
53 if (pending || !name.trim() || !readEnvironmentScope(environmentId, AuthProvidersManageScope))
54 return;
5155 setPending(true);
5256 // The ID is routing identity; the name is editable and need not be unique.
5357 const id = ProviderInstanceId.make(`codex_${randomUUID()}`);
......@@ -131,7 +135,11 @@ export function AddCodexAccountDialog({
131135 <Button variant="outline" disabled={pending} onClick={onClose}>
132136 Cancel
133137 </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 >
135143 {pending ? "Adding account…" : "Continue"}
136144 </Button>
137145 </>
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+101
......@@ -4,6 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44import { visitElements } from "../../test/reactElementTree";
55import { 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
714const settingsHooks = vi.hoisted(() => ({
815 read: vi.fn(() => ({ providerInstances: {} })),
916 mutate: vi.fn(),
......@@ -35,6 +42,15 @@ vi.mock("../../hooks/useSettings", () => ({
3542 usePersistEnvironmentProviderInstanceMutation: settingsHooks.useMutation,
3643}));
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
3854import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
3955
4056const remoteEnvironmentId = EnvironmentId.make("remote-device");
......@@ -81,9 +97,45 @@ async function selectPreparedAcp() {
8197 );
8298}
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
84133describe("AddProviderInstanceDialog environment routing", () => {
85134 beforeEach(() => {
86135 hooks.reset();
136 actions.canManageProviders = true;
137 actions.toast.mockReset();
138 actions.onOpenChange.mockReset();
87139 settingsHooks.read.mockReset().mockReturnValue({ providerInstances: {} });
88140 settingsHooks.mutate.mockReset().mockResolvedValue({ _tag: "Success", value: {} });
89141 settingsHooks.useMutation.mockReset().mockReturnValue(settingsHooks.mutate);
......@@ -321,4 +373,53 @@ describe("AddProviderInstanceDialog environment routing", () => {
321373
322374 expect(onOpenChange).not.toHaveBeenCalled();
323375 });
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 });
324425});
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+9-1
......@@ -9,6 +9,7 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"
99import {
1010 DEFAULT_UNIFIED_SETTINGS,
1111 type AcpRegistrySearchAgent,
12 AuthProvidersManageScope,
1213 ProviderInstanceId,
1314 ProviderDriverKind,
1415 type EnvironmentId,
......@@ -24,6 +25,7 @@ import * as Equal from "effect/Equal";
2425
2526import { cn } from "../../lib/utils";
2627import { normalizeProviderAccentColor } from "../../providerInstances";
28import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
2729import { Button } from "../ui/button";
2830import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
2931import { Dialog } from "../ui/dialog";
......@@ -113,6 +115,7 @@ export function AddProviderInstanceDialog({
113115}: AddProviderInstanceDialogProps) {
114116 const settings = useEnvironmentSettings(environmentId);
115117 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
118 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
116119
117120 const [wizardStep, setWizardStep] = useState(0);
118121 const [addingChatGptAccount, setAddingChatGptAccount] = useState(false);
......@@ -308,6 +311,7 @@ export function AddProviderInstanceDialog({
308311 };
309312
310313 const handleSave = async () => {
314 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
311315 if (isSaving || createdInstanceId) return;
312316 setHasAttemptedSubmit(true);
313317 if (instanceIdError !== null || (isAcpRegistry && acpSelectionError !== null)) return;
......@@ -694,7 +698,11 @@ export function AddProviderInstanceDialog({
694698 Next
695699 </Button>
696700 ) : (
697 <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}>
701 <Button
702 size="sm"
703 disabled={isSaving || !canManageProviders}
704 onClick={() => void handleSave()}
705 >
698706 {isSaving
699707 ? "Adding..."
700708 : isAcpRegistry && !isLocalAcp
apps/web/src/components/settings/CodexSetupSection.tsx+4
......@@ -1,3 +1,5 @@
1import { AuthProvidersManageScope } from "@t3tools/contracts";
2import { useEnvironmentScope } from "../../state/session";
13import {
24 isAtomCommandInterrupted,
35 squashAtomCommandFailure,
......@@ -167,6 +169,7 @@ export function AddManagedCodexAccountDialog({
167169 | ((instanceId: ProviderInstanceId, displayName: string) => void)
168170 | undefined;
169171}) {
172 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
170173 return (
171174 <AddCodexAccountDialog
172175 environmentId={environmentId}
......@@ -180,6 +183,7 @@ export function AddManagedCodexAccountDialog({
180183 provider={provider}
181184 mode="managed"
182185 enabled
186 readOnly={!canManageProviders}
183187 autoStart
184188 onAutoStartConsumed={noop}
185189 onModeChange={noop}
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+13
......@@ -2,6 +2,7 @@ import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts";
22import { describe, expect, it, vi } from "vite-plus/test";
33import {
44 applyWslEnableSelection,
5 canRevokeOtherClients,
56 isQrShareableEndpoint,
67 isWslSettingsRowVisible,
78 selectQrEndpointOption,
......@@ -183,3 +184,15 @@ describe("selectQrEndpointOption", () => {
183184 expect(selectQrEndpointOption([], "anything", "anything")).toBeNull();
184185 });
185186});
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 @@
11import 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
310type WslEnableBridge = Pick<DesktopBridge, "setWslBackendEnabled" | "setWslDistro" | "setWslOnly">;
411
512/**
apps/web/src/components/settings/ConnectionsSettings.tsx+281-121
......@@ -23,7 +23,9 @@ import {
2323import {
2424 AuthAccessReadScope,
2525 AuthAccessWriteScope,
26 AuthAdministrativeScopes,
26 AuthSettingsWriteScope,
27 AuthProvidersManageScope,
28 AuthEnvironmentMaintainScope,
2729 AuthOrchestrationOperateScope,
2830 AuthOrchestrationReadScope,
2931 AuthRelayReadScope,
......@@ -64,6 +66,7 @@ import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestam
6466import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
6567import {
6668 applyWslEnableSelection,
69 canRevokeOtherClients,
6770 isQrShareableEndpoint,
6871 isWslSettingsRowVisible,
6972 selectQrEndpointOption,
......@@ -162,6 +165,7 @@ import {
162165 connectPairing as connectPairingAtom,
163166 connectSshEnvironment as connectSshEnvironmentAtom,
164167} from "~/connection/onboarding";
168import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
165169import { useEnvironmentQuery } from "~/state/query";
166170import {
167171 desktopNetworkAccessStateAtom,
......@@ -174,6 +178,7 @@ import {
174178 useEnvironments,
175179 usePrimaryEnvironment,
176180 useRelayEnvironmentDiscovery,
181 usePrimaryEnvironmentId,
177182} from "~/state/environments";
178183import { APP_VERSION } from "~/branding";
179184import { requestConfirmDialog } from "~/confirmDialog";
......@@ -234,6 +239,21 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
234239 title: "Operate tasks",
235240 description: "Start tasks and perform changes in the environment.",
236241 },
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 },
237257 {
238258 scope: AuthTerminalOperateScope,
239259 title: "Use terminals",
......@@ -600,6 +620,7 @@ type PairingLinkListRowProps = {
600620 presentation?: AccessSectionPresentation;
601621 revokingPairingLinkId: string | null;
602622 onRevoke: (id: string) => void;
623 canRevoke: boolean;
603624};
604625
605626const PairingLinkListRow = memo(function PairingLinkListRow({
......@@ -611,6 +632,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
611632 presentation = "current",
612633 revokingPairingLinkId,
613634 onRevoke,
635 canRevoke,
614636}: PairingLinkListRowProps) {
615637 const nowMs = useRelativeTimeTick(1_000);
616638 const expiresAtMs = useMemo(
......@@ -877,7 +899,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
877899 <Button
878900 size="xs"
879901 variant="destructive-outline"
880 disabled={revokingPairingLinkId === pairingLink.id}
902 disabled={!canRevoke || revokingPairingLinkId === pairingLink.id}
881903 onClick={() => void onRevoke(pairingLink.id)}
882904 >
883905 {revokingPairingLinkId === pairingLink.id ? "Revoking…" : "Revoke"}
......@@ -982,6 +1004,7 @@ type ConnectedClientListRowProps = {
9821004 presentation?: AccessSectionPresentation;
9831005 revokingClientSessionId: string | null;
9841006 onRevokeSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
1007 canRevoke: boolean;
9851008};
9861009
9871010const ConnectedClientListRow = memo(function ConnectedClientListRow({
......@@ -989,6 +1012,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
9891012 presentation = "current",
9901013 revokingClientSessionId,
9911014 onRevokeSession,
1015 canRevoke,
9921016}: ConnectedClientListRowProps) {
9931017 const nowMs = useRelativeTimeTick(1_000);
9941018 const isLive = clientSession.current || clientSession.connected;
......@@ -1045,7 +1069,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
10451069 <Button
10461070 size="xs"
10471071 variant="destructive-outline"
1048 disabled={revokingClientSessionId === clientSession.sessionId}
1072 disabled={!canRevoke || revokingClientSessionId === clientSession.sessionId}
10491073 onClick={() => void onRevokeSession(clientSession.sessionId)}
10501074 >
10511075 {revokingClientSessionId === clientSession.sessionId ? "Revoking…" : "Revoke"}
......@@ -1059,9 +1083,10 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({
10591083
10601084type AuthorizedClientsHeaderActionProps = {
10611085 onPairingLinkCreated: (result: AuthPairingCredentialResult) => void;
1062 clientSessions: ReadonlyArray<ServerClientSessionRecord>;
1086 clientSessions: ReadonlyArray<ServerClientSessionRecord> | null;
10631087 isRevokingOtherClients: boolean;
10641088 onRevokeOtherClients: () => void;
1089 delegatableScopes: ReadonlyArray<AuthEnvironmentScope>;
10651090};
10661091
10671092const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderAction({
......@@ -1069,24 +1094,36 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
10691094 clientSessions,
10701095 isRevokingOtherClients,
10711096 onRevokeOtherClients,
1097 delegatableScopes,
10721098}: AuthorizedClientsHeaderActionProps) {
1099 const primaryEnvironmentId = usePrimaryEnvironmentId();
10731100 const [dialogOpen, setDialogOpen] = useState(false);
10741101 const [pairingLabel, setPairingLabel] = useState("");
10751102 const [pairingScopes, setPairingScopes] = useState<ReadonlyArray<AuthEnvironmentScope>>([
10761103 ...AuthStandardClientScopes,
10771104 ]);
1105 const selectedScopes = pairingScopes.filter((scope) => delegatableScopes.includes(scope));
10781106 const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false);
10791107
10801108 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;
10811116 setIsCreatingPairingLink(true);
10821117 try {
10831118 const created = await createServerPairingCredential({
10841119 label: pairingLabel,
1085 scopes: pairingScopes,
1120 scopes: selectedScopes,
10861121 });
10871122 onPairingLinkCreated(created);
10881123 setPairingLabel("");
1089 setPairingScopes([...AuthStandardClientScopes]);
1124 setPairingScopes(
1125 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1126 );
10901127 setDialogOpen(false);
10911128 } catch (error) {
10921129 const message = error instanceof Error ? error.message : "Failed to create pairing URL.";
......@@ -1100,7 +1137,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11001137 } finally {
11011138 setIsCreatingPairingLink(false);
11021139 }
1103 }, [onPairingLinkCreated, pairingLabel, pairingScopes]);
1140 }, [delegatableScopes, onPairingLinkCreated, pairingLabel, primaryEnvironmentId, selectedScopes]);
11041141
11051142 const togglePairingScope = useCallback((scope: AuthEnvironmentScope, checked: boolean) => {
11061143 setPairingScopes((current) =>
......@@ -1113,9 +1150,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11131150 <Button
11141151 size="xs"
11151152 variant="destructive-outline"
1116 disabled={
1117 isRevokingOtherClients || clientSessions.every((clientSession) => clientSession.current)
1118 }
1153 disabled={isRevokingOtherClients || !canRevokeOtherClients(clientSessions)}
11191154 onClick={() => void onRevokeOtherClients()}
11201155 >
11211156 {isRevokingOtherClients ? "Revoking…" : "Revoke others"}
......@@ -1126,7 +1161,9 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11261161 setDialogOpen(open);
11271162 if (!open) {
11281163 setPairingLabel("");
1129 setPairingScopes([...AuthStandardClientScopes]);
1164 setPairingScopes(
1165 AuthStandardClientScopes.filter((scope) => delegatableScopes.includes(scope)),
1166 );
11301167 }
11311168 }}
11321169 >
......@@ -1172,7 +1209,13 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11721209 size="xs"
11731210 variant="outline"
11741211 disabled={isCreatingPairingLink}
1175 onClick={() => setPairingScopes([AuthOrchestrationReadScope])}
1212 onClick={() =>
1213 setPairingScopes(
1214 delegatableScopes.includes(AuthOrchestrationReadScope)
1215 ? [AuthOrchestrationReadScope]
1216 : [],
1217 )
1218 }
11761219 >
11771220 Read only
11781221 </Button>
......@@ -1180,36 +1223,44 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
11801223 size="xs"
11811224 variant="outline"
11821225 disabled={isCreatingPairingLink}
1183 onClick={() => setPairingScopes([...AuthStandardClientScopes])}
1226 onClick={() =>
1227 setPairingScopes(
1228 AuthStandardClientScopes.filter((scope) =>
1229 delegatableScopes.includes(scope),
1230 ),
1231 )
1232 }
11841233 >
11851234 Standard
11861235 </Button>
11871236 </div>
11881237 </div>
11891238 <div className="divide-y divide-border/60 rounded-lg border border-input bg-muted/25">
1190 {PAIRING_SCOPE_OPTIONS.map(({ scope, title, description }) => (
1191 <label
1192 key={scope}
1193 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1194 >
1195 <Checkbox
1196 className="mt-0.5"
1197 checked={pairingScopes.includes(scope)}
1198 disabled={isCreatingPairingLink}
1199 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1200 />
1201 <span className="min-w-0">
1202 <span className="block text-xs font-medium text-foreground">{title}</span>
1203 <span className="block text-xs leading-snug text-muted-foreground">
1204 {description}
1239 {PAIRING_SCOPE_OPTIONS.filter(({ scope }) => delegatableScopes.includes(scope)).map(
1240 ({ scope, title, description }) => (
1241 <label
1242 key={scope}
1243 className="flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
1244 >
1245 <Checkbox
1246 className="mt-0.5"
1247 checked={pairingScopes.includes(scope)}
1248 disabled={isCreatingPairingLink}
1249 onCheckedChange={(checked) => togglePairingScope(scope, checked === true)}
1250 />
1251 <span className="min-w-0">
1252 <span className="block text-xs font-medium text-foreground">{title}</span>
1253 <span className="block text-xs leading-snug text-muted-foreground">
1254 {description}
1255 </span>
12051256 </span>
1206 </span>
1207 </label>
1208 ))}
1257 </label>
1258 ),
1259 )}
12091260 </div>
1210 {pairingScopes.length === 0 ? (
1261 {selectedScopes.length === 0 ? (
12111262 <p className="text-xs text-destructive">Select at least one permission.</p>
1212 ) : pairingScopes.includes(AuthAccessWriteScope) ? (
1263 ) : selectedScopes.includes(AuthAccessWriteScope) ? (
12131264 <p className="text-xs text-warning">
12141265 This client can create or revoke access for other devices.
12151266 </p>
......@@ -1225,7 +1276,7 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
12251276 Cancel
12261277 </Button>
12271278 <Button
1228 disabled={isCreatingPairingLink || pairingScopes.length === 0}
1279 disabled={isCreatingPairingLink || selectedScopes.length === 0}
12291280 onClick={() => void handleCreatePairingLink()}
12301281 >
12311282 {isCreatingPairingLink ? "Creating…" : "Create link"}
......@@ -1250,6 +1301,7 @@ type PairingClientsListProps = {
12501301 revokingClientSessionId: string | null;
12511302 onRevokePairingLink: (id: string) => void;
12521303 onRevokeClientSession: (sessionId: ServerClientSessionRecord["sessionId"]) => void;
1304 canRevoke: boolean;
12531305};
12541306
12551307const PairingClientsList = memo(function PairingClientsList({
......@@ -1265,6 +1317,7 @@ const PairingClientsList = memo(function PairingClientsList({
12651317 revokingClientSessionId,
12661318 onRevokePairingLink,
12671319 onRevokeClientSession,
1320 canRevoke,
12681321}: PairingClientsListProps) {
12691322 return (
12701323 <>
......@@ -1279,6 +1332,7 @@ const PairingClientsList = memo(function PairingClientsList({
12791332 presentation={presentation}
12801333 revokingPairingLinkId={revokingPairingLinkId}
12811334 onRevoke={onRevokePairingLink}
1335 canRevoke={canRevoke}
12821336 />
12831337 ))}
12841338
......@@ -1289,6 +1343,7 @@ const PairingClientsList = memo(function PairingClientsList({
12891343 presentation={presentation}
12901344 revokingClientSessionId={revokingClientSessionId}
12911345 onRevokeSession={onRevokeClientSession}
1346 canRevoke={canRevoke}
12921347 />
12931348 ))}
12941349
......@@ -1793,6 +1848,7 @@ function CloudLinkSwitch({
17931848}
17941849
17951850function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1851 const primaryEnvironmentId = usePrimaryEnvironmentId();
17961852 const {
17971853 isSignedIn,
17981854 linkState: primaryCloudLinkState,
......@@ -1813,6 +1869,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18131869 const isBusy = isUpdating || isUpdatingPreference;
18141870
18151871 const updateManagedTunnel = async (enabled: boolean) => {
1872 if (
1873 primaryEnvironmentId === null ||
1874 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1875 )
1876 return;
18161877 setIsUpdating(true);
18171878 const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity });
18181879 if (ok) {
......@@ -1836,6 +1897,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18361897 };
18371898
18381899 const updatePublishAgentActivity = async (enabled: boolean) => {
1900 if (
1901 primaryEnvironmentId === null ||
1902 !readEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope)
1903 )
1904 return;
18391905 setIsUpdatingPreference(true);
18401906 const ok = await reconcileCloudState({ managedTunnel: managedTunnelActive, publish: enabled });
18411907 if (ok) {
......@@ -1883,7 +1949,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18831949 control={
18841950 <CloudLinkSwitch
18851951 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 }
18871959 disabledReason={disabledReason}
18881960 onCheckedChange={(enabled) => void updateManagedTunnel(enabled)}
18891961 />
......@@ -1897,7 +1969,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
18971969 <CloudLinkSwitch
18981970 ariaLabel="Publish agent activity to mobile clients"
18991971 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 }
19011979 disabledReason={disabledReason}
19021980 onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)}
19031981 />
......@@ -1922,8 +2000,23 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
19222000 );
19232001}
19242002
1925function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1926 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;
2003function CloudLinkRow({
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} />;
19272020}
19282021
19292022function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) {
......@@ -1981,11 +2074,9 @@ export function ConnectionsSettings() {
19812074 });
19822075 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
19832076 const primarySessionState = usePrimarySessionState();
1984 const currentSessionScopes = desktopBridge
1985 ? AuthAdministrativeScopes
1986 : primarySessionState.data?.authenticated
1987 ? (primarySessionState.data.scopes ?? null)
1988 : null;
2077 const currentSessionScopes = primarySessionState.data?.authenticated
2078 ? (primarySessionState.data.scopes ?? null)
2079 : null;
19892080 const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null);
19902081 // Catalog order is the order the machines were added; rows never jump when
19912082 // one is switched off.
......@@ -2165,12 +2256,14 @@ export function ConnectionsSettings() {
21652256 const setDefaultAdvertisedEndpointKey = useUiStateStore(
21662257 (state) => state.setDefaultAdvertisedEndpointKey,
21672258 );
2168 const canManageLocalBackend =
2169 !isLocalEnvironmentDisabled() &&
2170 (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false);
2171 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
2259 const canReadAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessReadScope);
2260 const canWriteAccess = useEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope);
2261 const canReadRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayReadScope);
2262 const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
2263 const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope);
2264 const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain;
21722265 const authAccessChanges = useEnvironmentQuery(
2173 canManageLocalBackend && primaryEnvironmentId !== null
2266 canReadAccess && primaryEnvironmentId !== null
21742267 ? authEnvironment.accessChanges({
21752268 environmentId: primaryEnvironmentId,
21762269 input: null,
......@@ -2273,7 +2366,12 @@ export function ConnectionsSettings() {
22732366
22742367 const handleDesktopServerExposureChange = useCallback(
22752368 async (checked: boolean) => {
2276 if (!desktopBridge) return;
2369 if (
2370 !desktopBridge ||
2371 primaryEnvironmentId === null ||
2372 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2373 )
2374 return;
22772375 setIsUpdatingDesktopServerExposure(true);
22782376 setDesktopServerExposureMutationError(null);
22792377 try {
......@@ -2296,7 +2394,7 @@ export function ConnectionsSettings() {
22962394 setIsUpdatingDesktopServerExposure(false);
22972395 }
22982396 },
2299 [desktopBridge],
2397 [desktopBridge, primaryEnvironmentId],
23002398 );
23012399
23022400 const handleConfirmDesktopServerExposureChange = useCallback(() => {
......@@ -2306,7 +2404,12 @@ export function ConnectionsSettings() {
23062404 }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]);
23072405
23082406 const handleConfirmTailscaleServeSetup = useCallback(async () => {
2309 if (!desktopBridge) return;
2407 if (
2408 !desktopBridge ||
2409 primaryEnvironmentId === null ||
2410 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2411 )
2412 return;
23102413 if (!isTailscaleServePortValid) return;
23112414 setIsUpdatingTailscaleServe(true);
23122415 setDesktopServerExposureMutationError(null);
......@@ -2331,7 +2434,7 @@ export function ConnectionsSettings() {
23312434 } finally {
23322435 setIsUpdatingTailscaleServe(false);
23332436 }
2334 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]);
2437 }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort, primaryEnvironmentId]);
23352438
23362439 const handleStartTailscaleServeSetup = useCallback(
23372440 (endpoint: AdvertisedEndpoint) => {
......@@ -2344,7 +2447,12 @@ export function ConnectionsSettings() {
23442447 );
23452448
23462449 const handleConfirmTailscaleServeDisable = useCallback(async () => {
2347 if (!desktopBridge) return;
2450 if (
2451 !desktopBridge ||
2452 primaryEnvironmentId === null ||
2453 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
2454 )
2455 return;
23482456 setIsUpdatingTailscaleServe(true);
23492457 setDesktopServerExposureMutationError(null);
23502458 try {
......@@ -2367,34 +2475,47 @@ export function ConnectionsSettings() {
23672475 } finally {
23682476 setIsUpdatingTailscaleServe(false);
23692477 }
2370 }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]);
2478 }, [desktopBridge, desktopServerExposureState, primaryEnvironmentId]);
23712479
23722480 const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => {
23732481 setDisableTailscaleServeDialogOpen(true);
23742482 }, []);
23752483
2376 const handleRevokeDesktopPairingLink = useCallback(async (id: string) => {
2377 setRevokingDesktopPairingLinkId(id);
2378 setDesktopAccessManagementMutationError(null);
2379 try {
2380 await revokeServerPairingLink(id);
2381 } catch (error) {
2382 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
2383 setDesktopAccessManagementMutationError(message);
2384 toastManager.add(
2385 stackedThreadToast({
2386 type: "error",
2387 title: "Could not revoke pairing link",
2388 description: message,
2389 }),
2390 );
2391 } finally {
2392 setRevokingDesktopPairingLinkId(null);
2393 }
2394 }, []);
2484 const handleRevokeDesktopPairingLink = useCallback(
2485 async (id: string) => {
2486 if (
2487 primaryEnvironmentId === null ||
2488 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2489 )
2490 return;
2491 setRevokingDesktopPairingLinkId(id);
2492 setDesktopAccessManagementMutationError(null);
2493 try {
2494 await revokeServerPairingLink(id);
2495 } catch (error) {
2496 const message = error instanceof Error ? error.message : "Failed to revoke pairing link.";
2497 setDesktopAccessManagementMutationError(message);
2498 toastManager.add(
2499 stackedThreadToast({
2500 type: "error",
2501 title: "Could not revoke pairing link",
2502 description: message,
2503 }),
2504 );
2505 } finally {
2506 setRevokingDesktopPairingLinkId(null);
2507 }
2508 },
2509 [primaryEnvironmentId],
2510 );
23952511
23962512 const handleRevokeDesktopClientSession = useCallback(
23972513 async (sessionId: ServerClientSessionRecord["sessionId"]) => {
2514 if (
2515 primaryEnvironmentId === null ||
2516 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2517 )
2518 return;
23982519 setRevokingDesktopClientSessionId(sessionId);
23992520 setDesktopAccessManagementMutationError(null);
24002521 try {
......@@ -2413,10 +2534,15 @@ export function ConnectionsSettings() {
24132534 setRevokingDesktopClientSessionId(null);
24142535 }
24152536 },
2416 [],
2537 [primaryEnvironmentId],
24172538 );
24182539
24192540 const handleRevokeOtherDesktopClients = useCallback(async () => {
2541 if (
2542 primaryEnvironmentId === null ||
2543 !readEnvironmentScope(primaryEnvironmentId, AuthAccessWriteScope)
2544 )
2545 return;
24202546 setIsRevokingOtherDesktopClients(true);
24212547 setDesktopAccessManagementMutationError(null);
24222548 try {
......@@ -2439,7 +2565,7 @@ export function ConnectionsSettings() {
24392565 } finally {
24402566 setIsRevokingOtherDesktopClients(false);
24412567 }
2442 }, []);
2568 }, [primaryEnvironmentId]);
24432569
24442570 // Shared by manual SSH submission and discovered-host selection.
24452571 const connectSavedBackendSshTarget = useCallback(
......@@ -2743,8 +2869,6 @@ export function ConnectionsSettings() {
27432869 : visibleDesktopNetworkAdvertisedEndpoints,
27442870 [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints],
27452871 );
2746 const isLocalBackendRemotelyReachable =
2747 isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available";
27482872 const defaultDesktopNetworkAdvertisedEndpoint = useMemo(
27492873 () =>
27502874 selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey),
......@@ -3074,7 +3198,12 @@ export function ConnectionsSettings() {
30743198 // entry catches up (registers/unregisters) without a reload.
30753199 const applyWslSettingChange = useCallback(
30763200 async (apply: () => Promise<DesktopWslState>) => {
3077 if (!desktopBridge) return;
3201 if (
3202 !desktopBridge ||
3203 primaryEnvironmentId === null ||
3204 !readEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope)
3205 )
3206 return;
30783207 setIsUpdatingWslBackend(true);
30793208 setDesktopWslMutationError(null);
30803209 try {
......@@ -3099,7 +3228,7 @@ export function ConnectionsSettings() {
30993228 setIsUpdatingWslBackend(false);
31003229 }
31013230 },
3102 [desktopBridge],
3231 [desktopBridge, primaryEnvironmentId],
31033232 );
31043233
31053234 // Reload the keep-alive WSL state atom. Clearing the mutation error before
......@@ -3428,6 +3557,7 @@ export function ConnectionsSettings() {
34283557 revokingClientSessionId={revokingDesktopClientSessionId}
34293558 onRevokePairingLink={handleRevokeDesktopPairingLink}
34303559 onRevokeClientSession={handleRevokeDesktopClientSession}
3560 canRevoke={canWriteAccess}
34313561 />
34323562 </>
34333563 );
......@@ -3467,35 +3597,42 @@ export function ConnectionsSettings() {
34673597 <SettingsRow
34683598 title={searchableSetting("network-access").title}
34693599 description={
3470 currentAuthPolicy === "remote-reachable"
3471 ? "Remote access is already configured. Change network exposure where the server starts."
3472 : "Only this machine can connect. Restart with a non-loopback host for remote pairing."
3600 desktopBridge
3601 ? "This connection cannot view or change network exposure."
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."
34733607 }
34743608 control={
3475 <Tooltip>
3476 <TooltipTrigger
3477 render={
3478 <span className="inline-flex">
3479 <Switch
3480 checked={isLocalBackendNetworkAccessible}
3481 disabled
3482 aria-label="Enable network access"
3483 />
3484 </span>
3485 }
3486 />
3487 <TooltipPopup side="top">
3488 Network exposure changes restart the backend and must be controlled where the server
3489 process is launched.
3490 </TooltipPopup>
3491 </Tooltip>
3609 !desktopBridge &&
3610 (currentAuthPolicy === "remote-reachable" || currentAuthPolicy === "loopback-browser") ? (
3611 <Tooltip>
3612 <TooltipTrigger
3613 render={
3614 <span className="inline-flex">
3615 <Switch
3616 checked={isLocalBackendNetworkAccessible}
3617 disabled
3618 aria-label="Enable network access"
3619 />
3620 </span>
3621 }
3622 />
3623 <TooltipPopup side="top">
3624 Network exposure changes restart the backend and must be controlled where the server
3625 process is launched.
3626 </TooltipPopup>
3627 </Tooltip>
3628 ) : undefined
34923629 }
34933630 />
34943631 );
34953632
34963633 const primarySettings = (
34973634 <>
3498 {desktopBridge || canManageLocalBackend ? (
3635 {primaryEnvironmentId !== null ? (
34993636 <>
35003637 <SettingsSection
35013638 {...searchableSetting("connections-environment")}
......@@ -3591,17 +3728,21 @@ export function ConnectionsSettings() {
35913728 {renderEndpointRows("endpoint-rail")}
35923729 {renderTailscaleRow()}
35933730 {renderWslRow()}
3594 <CloudLinkRow canManageRelay={canManageRelay} />
3731 {canReadRelay || canManageRelay ? (
3732 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3733 ) : null}
35953734 </>
35963735 ) : canManageLocalBackend ? (
35973736 <>
35983737 {renderDisabledNetworkAccessRow()}
3599 <CloudLinkRow canManageRelay={canManageRelay} />
3738 {canReadRelay || canManageRelay ? (
3739 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
3740 ) : null}
36003741 </>
36013742 ) : null}
36023743 </SettingsSection>
36033744
3604 {isLocalBackendRemotelyReachable ? (
3745 {canReadAccess || canWriteAccess ? (
36053746 <FoldedSettingsSection
36063747 id="authorized-clients"
36073748 title="Authorized clients"
......@@ -3610,12 +3751,21 @@ export function ConnectionsSettings() {
36103751 visibleDesktopPairingLinks,
36113752 )}
36123753 control={
3613 <AuthorizedClientsHeaderAction
3614 onPairingLinkCreated={handlePairingLinkCreated}
3615 clientSessions={desktopClientSessions}
3616 isRevokingOtherClients={isRevokingOtherDesktopClients}
3617 onRevokeOtherClients={handleRevokeOtherDesktopClients}
3618 />
3754 canWriteAccess ? (
3755 <AuthorizedClientsHeaderAction
3756 delegatableScopes={currentSessionScopes ?? []}
3757 onPairingLinkCreated={handlePairingLinkCreated}
3758 clientSessions={
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
36193769 }
36203770 >
36213771 <ScrollArea
......@@ -3624,12 +3774,18 @@ export function ConnectionsSettings() {
36243774 className="max-h-[22.5rem]"
36253775 data-testid="authorized-clients-scroll-area"
36263776 >
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 )}
36283784 </ScrollArea>
36293785 </FoldedSettingsSection>
36303786 ) : null}
36313787 <AlertDialog
3632 open={isDesktopServerExposureDialogOpen}
3788 open={isDesktopServerExposureDialogOpen && canManageLocalBackend}
36333789 onOpenChange={(open) => {
36343790 if (isUpdatingDesktopServerExposure) return;
36353791 setIsDesktopServerExposureDialogOpen(open);
......@@ -3662,7 +3818,9 @@ export function ConnectionsSettings() {
36623818 variant="default"
36633819 onClick={handleConfirmDesktopServerExposureChange}
36643820 disabled={
3665 pendingDesktopServerExposureMode === null || isUpdatingDesktopServerExposure
3821 !canManageLocalBackend ||
3822 pendingDesktopServerExposureMode === null ||
3823 isUpdatingDesktopServerExposure
36663824 }
36673825 >
36683826 {isUpdatingDesktopServerExposure && <Spinner size="sm" />}
......@@ -3678,7 +3836,7 @@ export function ConnectionsSettings() {
36783836 </AlertDialogPopup>
36793837 </AlertDialog>
36803838 <AlertDialog
3681 open={isWslConfirmDialogOpen}
3839 open={isWslConfirmDialogOpen && canManageLocalBackend}
36823840 onOpenChange={(open) => {
36833841 if (isUpdatingWslBackend) return;
36843842 if (!open) setPendingWslChange(null);
......@@ -3725,7 +3883,7 @@ export function ConnectionsSettings() {
37253883 <Button
37263884 variant="outline"
37273885 onClick={() => handleConfirmEnableWsl("wsl-only")}
3728 disabled={isUpdatingWslBackend}
3886 disabled={isUpdatingWslBackend || !canManageLocalBackend}
37293887 >
37303888 {isUpdatingWslBackend ? (
37313889 <>
......@@ -3739,7 +3897,7 @@ export function ConnectionsSettings() {
37393897 <Button
37403898 variant="default"
37413899 onClick={() => handleConfirmEnableWsl("both")}
3742 disabled={isUpdatingWslBackend}
3900 disabled={isUpdatingWslBackend || !canManageLocalBackend}
37433901 >
37443902 {isUpdatingWslBackend ? (
37453903 <>
......@@ -3760,7 +3918,7 @@ export function ConnectionsSettings() {
37603918 : "default"
37613919 }
37623920 onClick={handleConfirmWslChange}
3763 disabled={isUpdatingWslBackend}
3921 disabled={isUpdatingWslBackend || !canManageLocalBackend}
37643922 >
37653923 {isUpdatingWslBackend ? (
37663924 <>
......@@ -3786,7 +3944,7 @@ export function ConnectionsSettings() {
37863944 </AlertDialogPopup>
37873945 </AlertDialog>
37883946 <AlertDialog
3789 open={disableTailscaleServeDialogOpen}
3947 open={disableTailscaleServeDialogOpen && canManageLocalBackend}
37903948 onOpenChange={(open) => {
37913949 if (isUpdatingTailscaleServe) return;
37923950 setDisableTailscaleServeDialogOpen(open);
......@@ -3809,7 +3967,7 @@ export function ConnectionsSettings() {
38093967 <Button
38103968 variant="destructive"
38113969 onClick={() => void handleConfirmTailscaleServeDisable()}
3812 disabled={isUpdatingTailscaleServe}
3970 disabled={isUpdatingTailscaleServe || !canManageLocalBackend}
38133971 >
38143972 {isUpdatingTailscaleServe ? (
38153973 <>
......@@ -3899,10 +4057,12 @@ export function ConnectionsSettings() {
38994057 ) : (
39004058 <SettingsSection {...searchableSetting("connections-environment")}>
39014059 <SettingsRow
3902 title="Administrative access"
3903 description="Pairing links and client-session management require the access:write scope for this backend."
4060 title="No environment selected"
4061 description="Connect an environment to view its settings and access."
39044062 />
3905 <CloudLinkRow canManageRelay={canManageRelay} />
4063 {canReadRelay || canManageRelay ? (
4064 <CloudLinkRow canReadRelay={canReadRelay} canManageRelay={canManageRelay} />
4065 ) : null}
39064066 </SettingsSection>
39074067 )}
39084068 </>
apps/web/src/components/settings/DeviceHostsSettings.tsx+10-2
......@@ -1,4 +1,6 @@
11import { DeviceToolVersions } from "../device/DeviceToolVersions";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
24import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
35import { AppleIcon, AndroidIcon } from "../Icons";
46import { Spinner } from "../ui/spinner";
......@@ -22,6 +24,7 @@ import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";
2224
2325export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
2426 const { scope, environments, connectedEnvironments } = useSettingsScope();
27 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
2528 const projectScope = scope.kind === "project" || scope.kind === "checkout";
2629 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
2730 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
......@@ -42,6 +45,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
4245 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
4346 throw new Error("Environment disconnected");
4447 }
48 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
49 throw new Error("This connection cannot change device settings.");
50 }
4551 return update({
4652 environmentId: environment.environmentId,
4753 input: {
......@@ -85,7 +91,9 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
8591 <Button
8692 size="sm"
8793 variant="outline"
88 disabled={projectScope || busy || !props.environmentId || editing !== null}
94 disabled={
95 !canConfigure || projectScope || busy || !props.environmentId || editing !== null
96 }
8997 onClick={() => {
9098 setOriginalHost(null);
9199 setEditing({ id: randomUUID(), label: "", target: "" });
......@@ -113,7 +121,7 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
113121 environmentLabel={environment.label}
114122 environmentId={environment.environmentId}
115123 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
116 busy={projectScope || busy}
124 busy={!canConfigure || projectScope || busy}
117125 checks={checks}
118126 testConnection={async (host) => {
119127 const results = await testConnection(host);
apps/web/src/components/settings/DiagnosticsSettings.tsx+34-23
......@@ -1,4 +1,7 @@
11import { ProcessSignalActions } from "./ProcessSignalActions";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
4import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
25import { RefreshIcon } from "~/components/ui/refresh-icon";
36import { AlertTriangleIcon, CopyIcon, FolderOpenIcon, InfoIcon } from "lucide-react";
47import { ChevronDown, ChevronRight } from "lucide";
......@@ -17,7 +20,7 @@ import * as Option from "effect/Option";
1720
1821import { cn } from "../../lib/utils";
1922import { ensureLocalApi } from "../../localApi";
20import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
23import { useOpenInPreferredEditor } from "../../editorPreferences";
2124import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
2225import { useEnvironmentQuery } from "../../state/query";
2326import { serverEnvironment } from "../../state/server";
......@@ -296,11 +299,13 @@ function ProcessNameCell({
296299
297300function ProcessDiagnosticsTable({
298301 processes,
302 canMaintainEnvironment,
299303 signalingPid,
300304 onSignal,
301305 emptyLabel,
302306}: {
303307 processes: ReadonlyArray<ServerProcessDiagnosticsEntry>;
308 canMaintainEnvironment: boolean;
304309 signalingPid: number | null;
305310 onSignal: (pid: number, signal: ServerProcessSignal) => void;
306311 emptyLabel?: string;
......@@ -408,7 +413,7 @@ function ProcessDiagnosticsTable({
408413 </td>
409414 <td className="p-2 align-middle sm:pr-4">
410415 <ProcessSignalActions
411 disabled={signalingPid === process.pid}
416 disabled={!canMaintainEnvironment || signalingPid === process.pid}
412417 onSignal={(signal) => onSignal(process.pid, signal)}
413418 />
414419 </td>
......@@ -710,14 +715,14 @@ export function DiagnosticsSettingsPanel() {
710715 // The boundary only mounts this page when the selection resolves to one
711716 // connected environment, so the representative is the one to inspect.
712717 const environmentId = environment?.environmentId ?? null;
718 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
713719 const observability = environment?.serverConfig?.observability;
714720 const availableEditors = environment?.serverConfig?.availableEditors;
721 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
715722 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
716723 reportFailure: false,
717724 });
718 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
719 reportFailure: false,
720 });
725 const openInEditor = useOpenInPreferredEditor(environmentId, availableEditors ?? []);
721726 const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000);
722727 const selectedResourceWindow =
723728 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
......@@ -773,26 +778,17 @@ export function DiagnosticsSettingsPanel() {
773778 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
774779 if (!logsDirectoryPath) return;
775780
776 const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
777 if (!editor) {
778 setOpenLogsDirectoryError("No available editors found.");
779 return;
780 }
781 if (environmentId === null) {
782 setOpenLogsDirectoryError("No environment is selected.");
781 if (
782 environmentId === null ||
783 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
784 ) {
783785 return;
784786 }
785787
786788 setIsOpeningLogsDirectory(true);
787789 setOpenLogsDirectoryError(null);
788790 void (async () => {
789 const result = await openInEditor({
790 environmentId,
791 input: {
792 cwd: logsDirectoryPath,
793 editor,
794 },
795 });
791 const result = await openInEditor(logsDirectoryPath);
796792 setIsOpeningLogsDirectory(false);
797793 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
798794 const error = squashAtomCommandFailure(result);
......@@ -801,7 +797,7 @@ export function DiagnosticsSettingsPanel() {
801797 );
802798 }
803799 })();
804 }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]);
800 }, [environmentId, observability?.logsDirectoryPath, openInEditor]);
805801
806802 const isInitialLoading = isPending && data === null;
807803 const isProcessInitialLoading = isProcessPending && processData === null;
......@@ -809,7 +805,12 @@ export function DiagnosticsSettingsPanel() {
809805 async (pid: number, signal: ServerProcessSignal) => {
810806 const targetEnvironmentId = environmentIdRef.current;
811807 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;
813814 if (signalingPidRef.current !== null) return;
814815 signalingPidRef.current = pid;
815816 setSignalingPid(pid);
......@@ -838,7 +839,12 @@ export function DiagnosticsSettingsPanel() {
838839 return;
839840 }
840841 }
841 if (environmentIdRef.current !== targetEnvironmentId) {
842 const currentEnvironmentId = environmentIdRef.current;
843 if (
844 currentEnvironmentId === null ||
845 currentEnvironmentId !== targetEnvironmentId ||
846 !readEnvironmentScope(currentEnvironmentId, AuthEnvironmentMaintainScope)
847 ) {
842848 clearSignaling();
843849 return;
844850 }
......@@ -956,6 +962,7 @@ export function DiagnosticsSettingsPanel() {
956962 ) : null}
957963 <ProcessDiagnosticsTable
958964 processes={processData?.processes ?? []}
965 canMaintainEnvironment={canMaintainEnvironment}
959966 signalingPid={signalingPid}
960967 onSignal={signalProcess}
961968 emptyLabel={
......@@ -1041,7 +1048,11 @@ export function DiagnosticsSettingsPanel() {
10411048 <Button
10421049 size="icon-xs"
10431050 variant="ghost-muted"
1044 disabled={!observability?.logsDirectoryPath || isOpeningLogsDirectory}
1051 disabled={
1052 !canOpenHostEditor ||
1053 !observability?.logsDirectoryPath ||
1054 isOpeningLogsDirectory
1055 }
10451056 onClick={openLogsDirectory}
10461057 aria-label="Open logs folder"
10471058 >
apps/web/src/components/settings/EnvironmentIconPicker.test.ts+2-2
......@@ -30,10 +30,10 @@ describe("resolveEnvironmentIconPickerLock", () => {
3030 ).toMatch(/cannot change/);
3131 });
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", () => {
3434 expect(
3535 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "pending" }),
36 ).toBeNull();
36 ).toMatch(/cannot change/);
3737 expect(
3838 resolveEnvironmentIconPickerLock({ serverConfig: config(true), operateAccess: "granted" }),
3939 ).toBeNull();
apps/web/src/components/settings/EnvironmentIconPicker.tsx+6-35
......@@ -1,4 +1,5 @@
11import {
2 AuthSettingsWriteScope,
23 ENVIRONMENT_MACHINE_KINDS,
34 isEnvironmentMachineKind,
45 resolveEnvironmentMachineKind,
......@@ -6,11 +7,8 @@ import {
67 type ServerConfig,
78} from "@t3tools/contracts";
89
9import { isElectron } from "../../env";
10import { usePrimarySessionState } from "../../environments/primary";
1110import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
12import { usePrimaryEnvironmentId } from "../../state/environments";
13import { useEnvironmentSessionState } from "../../state/session";
11import { useEnvironmentScope } from "../../state/session";
1412import { ENVIRONMENT_MACHINE_KIND_LABELS, EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
1513import {
1614 MenuItem,
......@@ -21,10 +19,6 @@ import {
2119 MenuSubPopup,
2220 MenuSubTrigger,
2321} from "../ui/menu";
24import {
25 resolvePrimaryOperateAccess,
26 resolveRemoteOperateAccess,
27} from "./ProviderSettingsPanel.logic";
2822
2923/**
3024 * Why the picker is inert, in the order the user can do something about it.
......@@ -40,37 +34,12 @@ export function resolveEnvironmentIconPickerLock(input: {
4034 if (input.serverConfig.environment.capabilities.environmentIcon !== true) {
4135 return "This environment's server is too old to keep an icon. Update it to choose one.";
4236 }
43 if (input.operateAccess === "denied") {
37 if (input.operateAccess !== "granted") {
4438 return "Your session on this environment cannot change its settings.";
4539 }
4640 return null;
4741}
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
7443/**
7544 * "Icon" submenu for an environment's row menu. Lists the machine kinds with
7645 * the server's own detection marked, so the user can tell whether detection
......@@ -86,7 +55,9 @@ export function EnvironmentIconMenu({
8655 readonly serverConfig: ServerConfig | null;
8756}) {
8857 const updateSettings = useUpdateEnvironmentSettings(environmentId);
89 const operateAccess = useEnvironmentOperateAccess(environmentId);
58 const operateAccess = useEnvironmentScope(environmentId, AuthSettingsWriteScope)
59 ? "granted"
60 : "denied";
9061 const lock = resolveEnvironmentIconPickerLock({ serverConfig, operateAccess });
9162 // With no detection the server falls back to "server", so picking that
9263 // 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(() => ({
1717 listBrowserImportSources: vi.fn().mockResolvedValue([]),
1818}));
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}));
2030vi.mock("../preview/previewBridge", () => ({
2131 previewBridge: { listBrowserImportSources },
2232}));
......@@ -24,6 +34,8 @@ vi.mock("../../env", () => ({ isElectron: true }));
2434vi.mock("../../state/environments", () => ({
2535 useEnvironments: () => ({ environments: [], isReady: true }),
2636 usePrimaryEnvironment: () => null,
37 // Settings rows resolve the primary grant before rendering server controls.
38 usePrimaryEnvironmentId: () => null,
2739}));
2840vi.mock("../../hooks/useSettings", async (importOriginal) => ({
2941 ...(await importOriginal<typeof import("../../hooks/useSettings")>()),
apps/web/src/components/settings/IntegrationsSettings.tsx+16-2
......@@ -12,6 +12,7 @@ import { DeviceHostsSettings } from "./DeviceHostsSettings";
1212 * @module IntegrationsSettings
1313 */
1414import {
15 AuthSettingsWriteScope,
1516 BrowserImportFailureReason,
1617 BROWSER_PROFILE_MAX_COUNT,
1718 type BrowserLinkTarget,
......@@ -49,6 +50,7 @@ import { previewBridge } from "~/components/preview/previewBridge";
4950import { cn, randomUUID } from "~/lib/utils";
5051import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
5152import { deviceEnvironment, useDeviceState } from "~/state/device";
53import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
5254import { useAtomCommand } from "~/state/use-atom-command";
5355import { previewEnvironment } from "~/state/preview";
5456import { useServerConfigs } from "~/state/entities";
......@@ -647,6 +649,7 @@ function DeviceIntegrationControls({
647649 enabled: boolean;
648650 agentAccessEnabled: boolean;
649651}) {
652 const canConfigure = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
650653 const { state, loaded } = useDeviceState(environmentId);
651654 const { scope, environments, connectedEnvironments } = useSettingsScope();
652655 const updateSettings = useUpdateScopedSettings();
......@@ -679,7 +682,7 @@ function DeviceIntegrationControls({
679682 kind: NonNullable<typeof pending>,
680683 input: { enabled?: boolean; agentAccessEnabled?: boolean },
681684 ) => {
682 if (!environmentId) return;
685 if (!environmentId || !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) return;
683686 setPending(kind);
684687 try {
685688 const results = await Promise.allSettled(
......@@ -687,6 +690,9 @@ function DeviceIntegrationControls({
687690 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
688691 throw new Error("Environment disconnected");
689692 }
693 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
694 throw new Error("This connection cannot change device settings.");
695 }
690696 return configure({
691697 environmentId: environment.environmentId,
692698 input: { ...input, ...(input.enabled ? { onboardingCompleted: true } : {}) },
......@@ -786,7 +792,14 @@ function DeviceIntegrationControls({
786792 <ScopedSwitch
787793 settingKeys={["enableDeviceSupport"]}
788794 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 }
790803 aria-label="Device hub"
791804 onCheckedChange={(checked) =>
792805 void update("hub", {
......@@ -851,6 +864,7 @@ function DeviceIntegrationControls({
851864 settingKeys={["enableAgentDeviceAccess"]}
852865 checked={agentAccessEnabled}
853866 disabled={
867 !canConfigure ||
854868 connectedEnvironments.length === 0 ||
855869 (!projectScope && (!loaded || !anyHubEnabled || busy)) ||
856870 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 @@
1import { useEnvironmentScope } from "../../state/session";
2import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
13import {
24 ChevronDownIcon,
35 CircleXIcon,
......@@ -22,6 +24,8 @@ import {
2224 useState,
2325} from "react";
2426import {
27 AuthOrchestrationOperateScope,
28 AuthSettingsWriteScope,
2529 type KeybindingCommand,
2630 type KeybindingWhenNode,
2731 type ServerRemoveKeybindingInput,
......@@ -1281,6 +1285,14 @@ export function KeybindingsSettingsPanel() {
12811285 // fan out to every connected environment in the selection, so one
12821286 // shortcut change reaches each machine the user runs T3 Code on.
12831287 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));
12841296 const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings;
12851297 const keybindings = useMemo(
12861298 () => mergeWithDefaultKeybindings(serverKeybindings ?? []),
......@@ -1351,7 +1363,12 @@ export function KeybindingsSettingsPanel() {
13511363 }, []);
13521364
13531365 const openKeybindingsFile = useCallback(() => {
1354 if (!keybindingsConfigPath) return;
1366 if (
1367 !keybindingsConfigPath ||
1368 !primaryEnvironment ||
1369 !readEnvironmentScope(primaryEnvironment.environmentId, AuthOrchestrationOperateScope)
1370 )
1371 return;
13551372 void (async () => {
13561373 const result = await openInPreferredEditor(keybindingsConfigPath);
13571374 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
......@@ -1365,11 +1382,17 @@ export function KeybindingsSettingsPanel() {
13651382 type: "error",
13661383 });
13671384 })();
1368 }, [keybindingsConfigPath, openInPreferredEditor]);
1385 }, [keybindingsConfigPath, openInPreferredEditor, primaryEnvironment]);
13691386
13701387 const saveKeybinding = useCallback(
13711388 (input: ServerUpsertKeybindingInput) => {
1372 if (!primaryEnvironment) return;
1389 if (
1390 !primaryEnvironment ||
1391 !connectedEnvironments.every((target) =>
1392 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1393 )
1394 )
1395 return;
13731396 setSavingCommand(input.command);
13741397 const payload: ServerUpsertKeybindingInput = {
13751398 command: input.command,
......@@ -1404,7 +1427,13 @@ export function KeybindingsSettingsPanel() {
14041427
14051428 const removeKeybinding = useCallback(
14061429 (row: KeybindingRow) => {
1407 if (!primaryEnvironment) return;
1430 if (
1431 !primaryEnvironment ||
1432 !connectedEnvironments.every((target) =>
1433 readEnvironmentScope(target.environmentId, AuthSettingsWriteScope),
1434 )
1435 )
1436 return;
14081437 setSavingCommand(row.command);
14091438 void (async () => {
14101439 const results = await Promise.all(
......@@ -1468,7 +1497,7 @@ export function KeybindingsSettingsPanel() {
14681497 <Button
14691498 type="button"
14701499 variant="outline"
1471 disabled={isAddingBinding}
1500 disabled={isAddingBinding || !canWriteSettings}
14721501 onClick={() => setIsAddingBinding(true)}
14731502 >
14741503 <PlusIcon aria-hidden className="size-4" />
......@@ -1481,7 +1510,7 @@ export function KeybindingsSettingsPanel() {
14811510 type="button"
14821511 variant="outline"
14831512 size="icon"
1484 disabled={!keybindingsConfigPath}
1513 disabled={!keybindingsConfigPath || !canOpenKeybindingsFile}
14851514 onClick={openKeybindingsFile}
14861515 aria-label="Open keybindings.json"
14871516 >
......@@ -1494,33 +1523,40 @@ export function KeybindingsSettingsPanel() {
14941523 </div>
14951524 </SettingsSection>
14961525
1497 {isAddingBinding ? (
1498 <SettingsGroup>
1499 <NewKeybindingSettingsRow
1500 commandOptions={commandOptions}
1501 allRows={rows}
1502 variables={whenVariables}
1503 isSaving={savingCommand !== null}
1504 onSave={saveKeybinding}
1505 onCancel={cancelAdd}
1506 />
1507 </SettingsGroup>
1526 {!canWriteSettings ? (
1527 <p className="text-xs text-muted-foreground">
1528 This connection can view keybindings but cannot change them.
1529 </p>
15081530 ) : 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 ? (
1511 <KeybindingsGroups
1512 groups={groups}
1513 anchorIds={anchorIds}
1514 savingCommand={savingCommand}
1515 {...rowActions}
1516 />
1517 ) : (
1518 <SettingsGroup>
1519 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1520 No keybindings match your search.
1521 </div>
1522 </SettingsGroup>
1523 )}
1545 {groups.length > 0 ? (
1546 <KeybindingsGroups
1547 groups={groups}
1548 anchorIds={anchorIds}
1549 savingCommand={savingCommand}
1550 {...rowActions}
1551 />
1552 ) : (
1553 <SettingsGroup>
1554 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1555 No keybindings match your search.
1556 </div>
1557 </SettingsGroup>
1558 )}
1559 </div>
15241560 </SettingsPageContainer>
15251561 );
15261562}
apps/web/src/components/settings/ProjectActionsSettings.tsx+19-12
......@@ -1,3 +1,5 @@
1import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
13import { EnvironmentId, type T3ProjectFileScript } from "@t3tools/contracts";
24import {
35 isAtomCommandInterrupted,
......@@ -52,6 +54,7 @@ export function ProjectActionsSettings() {
5254 (candidate) =>
5355 JSON.stringify(candidate.settings.defaultProjectScripts) !== JSON.stringify(scripts),
5456 );
57 const canWriteSettings = useScopedSettingsWriteAllowed();
5558 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
5659 const memberById = new Map(
5760 isProjectScope ? scope.members.map((member) => [member.id, member]) : [],
......@@ -148,7 +151,7 @@ export function ProjectActionsSettings() {
148151 id="import-scripts"
149152 size="xs"
150153 variant="ghost"
151 disabled={saving}
154 disabled={saving || !canWriteSettings}
152155 type="button"
153156 />
154157 }
......@@ -184,7 +187,7 @@ export function ProjectActionsSettings() {
184187 <Button
185188 size="xs"
186189 variant="outline"
187 disabled={saving || targets.length === 0}
190 disabled={saving || !canWriteSettings || targets.length === 0}
188191 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
189192 >
190193 <PlusIcon className="size-3.5" />
......@@ -202,7 +205,7 @@ export function ProjectActionsSettings() {
202205 <ProjectActionsList
203206 scripts={scripts}
204207 keybindings={keybindings}
205 disabled={saving}
208 disabled={saving || !canWriteSettings}
206209 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
207210 />
208211 )}
......@@ -213,15 +216,19 @@ export function ProjectActionsSettings() {
213216 className="text-warning"
214217 />
215218 ) : null}
216 <ProjectScriptEditorDialog
217 request={request}
218 scripts={scripts}
219 onSubmit={submit}
220 onDelete={(id) =>
221 void persist((current) => current.filter((script) => script.id !== id), id, null)
222 }
223 onClose={() => setRequest(null)}
224 />
219 {target && (
220 <ProjectScriptEditorDialog
221 environmentId={target.environmentId}
222 editScope={AuthSettingsWriteScope}
223 request={request}
224 scripts={scripts}
225 onSubmit={submit}
226 onDelete={(id) =>
227 void persist((current) => current.filter((script) => script.id !== id), id, null)
228 }
229 onClose={() => setRequest(null)}
230 />
231 )}
225232 </SettingsSection>
226233 );
227234}
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 @@
1import { useComposerMenuState } from "../chat/useComposerMenuState";
2import { useOrchestrationCommand } from "../../state/use-orchestration-command";
3import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
4import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session";
15import {
26 isAtomCommandInterrupted,
37 mapAtomCommandResult,
......@@ -23,7 +27,6 @@ import {
2327import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
2428import { useThreadShells } from "../../state/entities";
2529import { projectEnvironment } from "../../state/projects";
26import { useAtomCommand } from "../../state/use-atom-command";
2730import { ProjectFavicon } from "../ProjectFavicon";
2831import { Alert, AlertDescription } from "../ui/alert";
2932import { Button } from "../ui/button";
......@@ -171,13 +174,21 @@ function ProjectDetail({
171174 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
172175 [environments],
173176 );
177 const editableIds = useEnvironmentsWithScope(group.memberProjects, AuthOrchestrationOperateScope);
178 const canEditGroup = group.memberProjects.every((member) =>
179 editableIds.has(member.environmentId),
180 );
174181 const representative =
175182 group.memberProjects.find(
176183 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
177184 ) ?? group.memberProjects[0]!;
178185 const threads = useThreadShells();
179 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
180 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
186 const updateProject = useOrchestrationCommand(projectEnvironment.update, {
187 reportFailure: false,
188 });
189 const deleteProject = useOrchestrationCommand(projectEnvironment.delete, {
190 reportFailure: false,
191 });
181192 const projectNameEditedRef = useRef(false);
182193
183194 const faviconPath = representative.faviconPath ?? null;
......@@ -204,6 +215,25 @@ function ProjectDetail({
204215 );
205216 }, []);
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
207237 // Group-shared fields live on each physical project record, so a
208238 // group-level edit fans out to every member.
209239 const updateAllMembers = useCallback(
......@@ -215,6 +245,8 @@ function ProjectDetail({
215245 }>,
216246 failureTitle: string,
217247 ): Promise<AtomCommandResult<void, unknown>> => {
248 const denied = checkProjectAccess(group.memberProjects, failureTitle);
249 if (denied) return denied;
218250 const unavailable = group.memberProjects.find((member) => {
219251 const environment = environmentById.get(member.environmentId);
220252 return environment?.connection.phase !== "connected" || !environment.serverConfig;
......@@ -228,6 +260,8 @@ function ProjectDetail({
228260 return result;
229261 }
230262 for (const member of group.memberProjects) {
263 const revoked = checkProjectAccess([member], failureTitle);
264 if (revoked) return revoked;
231265 const result = mapAtomCommandResult(
232266 await updateProject({
233267 environmentId: member.environmentId,
......@@ -249,7 +283,7 @@ function ProjectDetail({
249283 }
250284 return AsyncResult.success(undefined);
251285 },
252 [environmentById, group.memberProjects, reportFailure, updateProject],
286 [checkProjectAccess, environmentById, group.memberProjects, reportFailure, updateProject],
253287 );
254288
255289 const renameGroup = useCallback(
......@@ -274,8 +308,8 @@ function ProjectDetail({
274308 );
275309
276310 // ----- project icon -----
277 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
278 const [iconPickerOpen, setIconPickerOpen] = useState(false);
311 const [faviconPickerOpen, setFaviconPickerOpen] = useComposerMenuState(!canEditGroup);
312 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditGroup);
279313 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
280314 const savingFaviconRef = useRef(false);
281315 const setProjectIcon = useCallback(
......@@ -297,6 +331,7 @@ function ProjectDetail({
297331
298332 const removeMembers = useCallback(
299333 async (members: ReadonlyArray<SidebarProjectGroupMember>) => {
334 if (checkProjectAccess(members, "Failed to remove project")) return;
300335 const api = readLocalApi();
301336 if (!api) return;
302337
......@@ -336,9 +371,11 @@ function ProjectDetail({
336371 ),
337372 );
338373 if (confirmed._tag === "Failure" || !confirmed.value) return;
374 if (checkProjectAccess(members, "Failed to remove project")) return;
339375
340376 const draftStore = useComposerDraftStore.getState();
341377 for (const member of members) {
378 if (checkProjectAccess([member], "Failed to remove project")) return;
342379 const memberThreads = projectThreads.filter(
343380 (thread) =>
344381 thread.environmentId === member.environmentId && thread.projectId === member.id,
......@@ -374,6 +411,7 @@ function ProjectDetail({
374411 }
375412 },
376413 [
414 checkProjectAccess,
377415 deleteProject,
378416 group.displayName,
379417 group.memberProjects.length,
......@@ -395,6 +433,7 @@ function ProjectDetail({
395433 <Button
396434 size="sm"
397435 variant="outline"
436 disabled={!editableIds.has(member.environmentId)}
398437 onClick={() => void removeMembers([member])}
399438 aria-label={`Remove checkout ${member.workspaceRoot}`}
400439 >
......@@ -416,6 +455,13 @@ function ProjectDetail({
416455 </AlertDescription>
417456 </Alert>
418457 <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}
419465 <SettingsRow
420466 title="Name"
421467 description="The shared name for this project group in the sidebar and thread lists."
......@@ -425,6 +471,7 @@ function ProjectDetail({
425471 size="sm"
426472 className="w-full sm:w-64"
427473 aria-label="Project name"
474 disabled={!canEditGroup}
428475 defaultValue={group.displayName}
429476 onChange={() => {
430477 projectNameEditedRef.current = true;
......@@ -457,7 +504,7 @@ function ProjectDetail({
457504 ) ? (
458505 <SettingResetButton
459506 label="project icon"
460 disabled={isSavingFavicon}
507 disabled={isSavingFavicon || !canEditGroup}
461508 onClick={() => void setProjectIcon({ faviconPath: null, projectIcon: null })}
462509 />
463510 ) : null
......@@ -470,7 +517,7 @@ function ProjectDetail({
470517 variant="outline"
471518 type="button"
472519 aria-label="Choose a project icon"
473 disabled={isSavingFavicon}
520 disabled={isSavingFavicon || !canEditGroup}
474521 onClick={() => setIconPickerOpen(true)}
475522 >
476523 Choose icon
......@@ -480,7 +527,7 @@ function ProjectDetail({
480527 variant="outline"
481528 type="button"
482529 aria-label="Choose a project icon file"
483 disabled={isSavingFavicon}
530 disabled={isSavingFavicon || !canEditGroup}
484531 onClick={() => setFaviconPickerOpen(true)}
485532 >
486533 Choose file
......@@ -512,6 +559,7 @@ function ProjectDetail({
512559 <Button
513560 size="sm"
514561 variant="destructive-outline"
562 disabled={!canEditGroup}
515563 onClick={() => void removeMembers(group.memberProjects)}
516564 >
517565 <Trash2Icon />
......@@ -535,10 +583,10 @@ function ProjectDetail({
535583 ? { onPickExternal: () => pickProjectFavicon(representative.workspaceRoot) }
536584 : {})}
537585 onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })}
538 open={faviconPickerOpen}
586 open={faviconPickerOpen && canEditGroup}
539587 projectName={group.displayName}
540588 />
541 {iconPickerOpen ? (
589 {iconPickerOpen && canEditGroup ? (
542590 <Suspense fallback={null}>
543591 <ProjectIconPickerDialog
544592 current={projectIcon}
apps/web/src/components/settings/ProviderInstanceCard.tsx+14-15
......@@ -478,6 +478,7 @@ interface ProviderInstanceCardProps {
478478 readonly selected?: boolean | undefined;
479479 readonly onSelect?: (() => void) | undefined;
480480 readonly readOnly?: boolean | undefined;
481 readonly canWriteSettings?: boolean;
481482 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
482483 /**
483484 * Pass `undefined` to hide the delete footer entirely. Built-in default
......@@ -546,6 +547,7 @@ export function ProviderInstanceCard({
546547 selected = false,
547548 onSelect,
548549 readOnly = false,
550 canWriteSettings = true,
549551 onUpdate,
550552 onDelete,
551553 headerAction,
......@@ -1116,30 +1118,27 @@ export function ProviderInstanceCard({
11161118 environment={genericEnvironment}
11171119 onChange={updateGenericEnvironment}
11181120 />
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}
11281121 </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
11301132 {driverOption !== undefined ? (
1131 <SettingsSection
1132 title="Models"
1133 inert={readOnly}
1134 aria-disabled={readOnly || undefined}
1135 className={readOnly ? "opacity-50 select-none" : undefined}
1136 >
1133 <SettingsSection title="Models">
11371134 <div className="px-3 py-3 sm:px-4">
11381135 <p className="mb-3 text-xs text-muted-foreground">
11391136 Favorites, visibility, and ordering are saved on this device. Custom models are saved
11401137 on the selected environment.
11411138 </p>
11421139 <ProviderModelsSection
1140 canManageCustomModels={!readOnly}
1141 canWritePreferences={canWriteSettings}
11431142 instanceId={instanceId}
11441143 driverKind={driverKind}
11451144 models={modelsForDisplay}
apps/web/src/components/settings/ProviderModelsSection.tsx+22-10
......@@ -131,6 +131,8 @@ interface ProviderModelsSectionProps {
131131 * removed) via `onChange`.
132132 */
133133 readonly customModels: ReadonlyArray<CustomModelDefinition>;
134 readonly canManageCustomModels: boolean;
135 readonly canWritePreferences?: boolean;
134136 /** Server-returned model slugs hidden from the model picker. */
135137 readonly hiddenModels: ReadonlyArray<string>;
136138 /** Model slugs favorited for this provider instance. */
......@@ -164,6 +166,8 @@ export function ProviderModelsSection({
164166 driverKind,
165167 models,
166168 customModels,
169 canManageCustomModels,
170 canWritePreferences = true,
167171 hiddenModels,
168172 favoriteModels,
169173 modelOrder,
......@@ -224,7 +228,7 @@ export function ProviderModelsSection({
224228 }, [displayModels]);
225229
226230 const handleAdd = () => {
227 if (driverKind === "antigravity") return;
231 if (!canManageCustomModels || driverKind === "antigravity") return;
228232 const normalized = normalizeCustomModelSlug(input);
229233 if (!normalized) {
230234 setError("Enter a model slug.");
......@@ -260,6 +264,7 @@ export function ProviderModelsSection({
260264 };
261265
262266 const handleRemove = (slug: string) => {
267 if (!canManageCustomModels) return;
263268 if (editingSlug === slug) setEditingSlug(null);
264269 onChange(customModels.filter((entry) => entry.slug !== slug));
265270 onModelOrderChange(modelOrder.filter((model) => model !== slug));
......@@ -268,18 +273,20 @@ export function ProviderModelsSection({
268273 };
269274
270275 const handleSaveEdit = (next: CustomModelDefinition) => {
276 if (!canManageCustomModels) return;
271277 onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));
272278 setEditingSlug(null);
273279 };
274280
275281 const setHidden = (slug: string, hidden: boolean) => {
276 if (hidden === hiddenModelSet.has(slug)) return;
282 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;
277283 onHiddenModelsChange(
278284 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
279285 );
280286 };
281287
282288 const handleToggleFavorite = (slug: string) => {
289 if (!canWritePreferences) return;
283290 if (favoriteModelSet.has(slug)) {
284291 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
285292 return;
......@@ -296,6 +303,7 @@ export function ProviderModelsSection({
296303 ? "hidden"
297304 : "visible";
298305 const handleMove = (slug: string, direction: -1 | 1) => {
306 if (!canWritePreferences) return;
299307 const index = displayModels.findIndex((model) => model.slug === slug);
300308 const nextIndex = index + direction;
301309 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;
......@@ -314,6 +322,7 @@ export function ProviderModelsSection({
314322 <Button
315323 size="icon-micro"
316324 variant="ghost-muted"
325 disabled={!canWritePreferences}
317326 onClick={() => handleToggleFavorite(model.slug)}
318327 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${
319328 isFavorite ? "from" : "to"
......@@ -348,7 +357,7 @@ export function ProviderModelsSection({
348357 <Button
349358 size="icon-micro"
350359 variant="ghost-muted"
351 disabled={!options.canMoveUp}
360 disabled={!canWritePreferences || !options.canMoveUp}
352361 onClick={() => handleMove(model.slug, -1)}
353362 aria-label={`Move ${model.name} up`}
354363 />
......@@ -364,7 +373,7 @@ export function ProviderModelsSection({
364373 <Button
365374 size="icon-micro"
366375 variant="ghost-muted"
367 disabled={!options.canMoveDown}
376 disabled={!canWritePreferences || !options.canMoveDown}
368377 onClick={() => handleMove(model.slug, 1)}
369378 aria-label={`Move ${model.name} down`}
370379 />
......@@ -384,10 +393,12 @@ export function ProviderModelsSection({
384393 <Button
385394 size="icon-micro"
386395 variant="ghost-muted"
396 disabled={!canManageCustomModels}
387397 aria-label={`Edit ${model.slug}`}
388 onClick={() =>
389 setEditingSlug((current) => (current === model.slug ? null : model.slug))
390 }
398 onClick={() => {
399 if (!canManageCustomModels) return;
400 setEditingSlug((current) => (current === model.slug ? null : model.slug));
401 }}
391402 />
392403 }
393404 >
......@@ -401,6 +412,7 @@ export function ProviderModelsSection({
401412 <Button
402413 size="icon-micro"
403414 variant="ghost-muted"
415 disabled={!canManageCustomModels}
404416 aria-label={`Remove ${model.slug}`}
405417 onClick={() => handleRemove(model.slug)}
406418 />
......@@ -430,7 +442,7 @@ export function ProviderModelsSection({
430442 <Switch
431443 size="sm"
432444 checked={!isHidden}
433 disabled={model.isCustom}
445 disabled={!canWritePreferences || model.isCustom}
434446 onCheckedChange={(checked) => setHidden(model.slug, !checked)}
435447 aria-label={`Show ${model.name} in the model picker`}
436448 />
......@@ -562,7 +574,7 @@ export function ProviderModelsSection({
562574 const previous = visibleModels[index - 1];
563575 const startsGroup = previous === undefined || groupOf(previous) !== group;
564576 const editingEntry =
565 model.isCustom && editingSlug === model.slug
577 canManageCustomModels && model.isCustom && editingSlug === model.slug
566578 ? customModels.find((entry) => entry.slug === model.slug)
567579 : undefined;
568580 return (
......@@ -593,7 +605,7 @@ export function ProviderModelsSection({
593605 })}
594606 </div>
595607
596 {driverKind === "antigravity" ? null : isAdding ? (
608 {driverKind === "antigravity" || !canManageCustomModels ? null : isAdding ? (
597609 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
598610 <Input
599611 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(() => ({
2626 updateProvider: vi.fn(),
2727 uninstall: vi.fn(),
2828 acceptUrlAuth: vi.fn(),
29 canManageProviders: true,
30 canWriteSettings: true,
2931}));
3032
3133const settingsState = vi.hoisted(() => ({
......@@ -118,6 +120,20 @@ vi.mock("../../environments/primary", () => ({
118120
119121vi.mock("../../state/session", () => ({
120122 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"),
121137}));
122138
123139vi.mock("../../state/entities", () => ({
......@@ -125,6 +141,7 @@ vi.mock("../../state/entities", () => ({
125141}));
126142
127143import { EnvironmentProviderSettings } from "./ProviderSettingsPanel";
144import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
128145
129146const environmentId = EnvironmentId.make("remote-device");
130147const codexId = ProviderInstanceId.make("codex");
......@@ -209,6 +226,8 @@ describe("EnvironmentProviderSettings routing", () => {
209226 settingsState.mutateProviderInstance
210227 .mockReset()
211228 .mockResolvedValue({ _tag: "Success", value: {} });
229 commands.canManageProviders = true;
230 commands.canWriteSettings = true;
212231 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
213232 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });
214233 commands.uninstall.mockReset().mockResolvedValue({ _tag: "Success", value: {} });
......@@ -355,6 +374,7 @@ describe("EnvironmentProviderSettings routing", () => {
355374 });
356375
357376 it("keeps provider selection available while write controls are read only", () => {
377 commands.canWriteSettings = false;
358378 settingsState.value = {
359379 ...DEFAULT_UNIFIED_SETTINGS,
360380 providerInstances: {
......@@ -388,7 +408,7 @@ describe("EnvironmentProviderSettings routing", () => {
388408 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
389409 expect(notice).not.toBeNull();
390410
391 expect(visitElements(panel, isRefreshButton)).toBeNull();
411 expect(visitElements(panel, isRefreshButton)).not.toBeNull();
392412 expect(visitElements(panel, isAddProviderButton)).toBeNull();
393413 });
394414
......@@ -403,6 +423,24 @@ describe("EnvironmentProviderSettings routing", () => {
403423 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
404424 });
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
406444 it("keeps Advanced visible when search targets the provider health interval", () => {
407445 let panel = renderPanel();
408446 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 @@
1import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts";
1import { AuthProvidersManageScope, EnvironmentId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
......@@ -136,7 +136,7 @@ describe("provider environment access", () => {
136136describe("primary operate access", () => {
137137 const authenticated = {
138138 authenticated: true as const,
139 scopes: [AuthOrchestrationOperateScope],
139 scopes: [AuthProvidersManageScope],
140140 };
141141
142142 it("keeps cached session data authoritative while SWR revalidates", () => {
......@@ -163,7 +163,7 @@ describe("primary operate access", () => {
163163 ).toBe("pending");
164164 });
165165
166 it("treats a failed session fetch as a transport problem, not a denial", () => {
166 it("denies writes when the session fetch fails", () => {
167167 expect(
168168 resolvePrimaryOperateAccess({
169169 isPrimary: true,
......@@ -172,7 +172,7 @@ describe("primary operate access", () => {
172172 isPending: false,
173173 hasError: true,
174174 }),
175 ).toBe("granted");
175 ).toBe("denied");
176176 });
177177
178178 it("denies unauthenticated sessions and sessions without the operate scope", () => {
......@@ -205,7 +205,7 @@ describe("primary operate access", () => {
205205 ).toBe("denied");
206206 });
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", () => {
209209 expect(
210210 resolvePrimaryOperateAccess({
211211 isPrimary: true,
......@@ -214,7 +214,7 @@ describe("primary operate access", () => {
214214 isPending: true,
215215 hasError: false,
216216 }),
217 ).toBe("granted");
217 ).toBe("pending");
218218 expect(
219219 resolvePrimaryOperateAccess({
220220 isPrimary: false,
......@@ -223,15 +223,24 @@ describe("primary operate access", () => {
223223 isPending: true,
224224 hasError: false,
225225 }),
226 ).toBe("granted");
226 ).toBe("pending");
227227 });
228228});
229229
230230describe("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 });
231240 it("derives access from the environment session's granted scopes", () => {
232241 expect(
233242 resolveRemoteOperateAccess({
234 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
243 session: { authenticated: true, scopes: [AuthProvidersManageScope] },
235244 isPending: false,
236245 hasError: false,
237246 }),
......@@ -258,18 +267,16 @@ describe("remote operate access", () => {
258267 );
259268 expect(
260269 resolveRemoteOperateAccess({
261 session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] },
270 session: { authenticated: true, scopes: [AuthProvidersManageScope] },
262271 isPending: true,
263272 hasError: false,
264273 }),
265274 ).toBe("granted");
266275 });
267276
268 it("stays optimistic when the session fetch fails or an older server omits scopes", () => {
269 // Transport failures and pre-scope-reporting servers are not permission
270 // decisions; the environment RPC layer still rejects unauthorized writes.
277 it("denies writes when the session fetch fails or scopes are missing", () => {
271278 expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe(
272 "granted",
279 "denied",
273280 );
274281 expect(
275282 resolveRemoteOperateAccess({
......@@ -277,6 +284,6 @@ describe("remote operate access", () => {
277284 isPending: false,
278285 hasError: false,
279286 }),
280 ).toBe("granted");
287 ).toBe("denied");
281288 });
282289});
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+9-51
......@@ -1,6 +1,6 @@
11import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
22import {
3 AuthOrchestrationOperateScope,
3 AuthProvidersManageScope,
44 type AuthSessionState,
55 type EnvironmentId,
66} from "@t3tools/contracts";
......@@ -74,46 +74,19 @@ export type ProviderEnvironmentAccess =
7474 */
7575export type ProviderOperateAccess = "granted" | "denied" | "pending";
7676
77/**
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 */
77/** Cached grants remain usable during revalidation; unknown or failed lookups grant nothing. */
9278function resolveSessionOperateAccess(input: {
9379 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
9480 readonly isPending: boolean;
9581 readonly hasError: boolean;
96 readonly missingScopesAccess: "granted" | "denied";
9782}): ProviderOperateAccess {
98 if (input.session === null) {
99 if (input.isPending) {
100 return "pending";
101 }
102 // A failed session fetch is a transport problem, not a permission
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";
83 if (input.hasError) return "denied";
84 if (input.session === null) return input.isPending ? "pending" : "denied";
85 return input.session.authenticated && input.session.scopes?.includes(AuthProvidersManageScope)
86 ? "granted"
87 : "denied";
11488}
11589
116/** Operate access for the primary environment's own browser session. */
11790export function resolvePrimaryOperateAccess(input: {
11891 readonly isPrimary: boolean;
11992 readonly hasDesktopBridge: boolean;
......@@ -121,30 +94,15 @@ export function resolvePrimaryOperateAccess(input: {
12194 readonly isPending: boolean;
12295 readonly hasError: boolean;
12396}): ProviderOperateAccess {
124 if (!input.isPrimary || input.hasDesktopBridge) {
125 return "granted";
126 }
127 return resolveSessionOperateAccess({
128 session: input.session,
129 isPending: input.isPending,
130 hasError: input.hasError,
131 missingScopesAccess: "denied",
132 });
97 return resolveSessionOperateAccess(input);
13398}
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 */
139100export function resolveRemoteOperateAccess(input: {
140101 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
141102 readonly isPending: boolean;
142103 readonly hasError: boolean;
143104}): ProviderOperateAccess {
144 return resolveSessionOperateAccess({
145 ...input,
146 missingScopesAccess: "granted",
147 });
105 return resolveSessionOperateAccess(input);
148106}
149107
150108export function classifyProviderEnvironmentAccess(input: {
apps/web/src/components/settings/ProviderSettingsPanel.tsx+67-110
......@@ -1,5 +1,11 @@
11import { SettingsGroup } from "./SettingsGroup";
22import { RefreshIcon } from "~/components/ui/refresh-icon";
3import {
4 AuthSettingsWriteScope,
5 AuthProvidersManageScope,
6 AuthOrchestrationReadScope,
7} from "@t3tools/contracts";
8import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
39import { useAtomValue } from "@effect/atom-react";
410import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
511import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
......@@ -31,8 +37,6 @@ import { PlusIcon } from "lucide-react";
3137import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3238
3339import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
34import { isElectron } from "../../env";
35import { usePrimarySessionState } from "../../environments/primary";
3640import {
3741 useEnvironmentSettings,
3842 usePersistEnvironmentProviderInstanceMutation,
......@@ -115,7 +119,6 @@ import {
115119 isProviderSettingsEnvironmentAvailable,
116120 type ProviderEnvironmentAccess,
117121 type ProviderOperateAccess,
118 resolvePrimaryOperateAccess,
119122 resolveRemoteOperateAccess,
120123 resolveSelectedProviderEnvironmentId,
121124} from "./ProviderSettingsPanel.logic";
......@@ -465,28 +468,6 @@ function SelectedEnvironmentProviderSettings({
465468 readonly deviceTabs?: ReactNode;
466469 readonly targetInstanceId?: ProviderInstanceId | undefined;
467470}) {
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 }
490471 return (
491472 <RemoteSessionGatedProviderSettings
492473 environment={environment}
......@@ -496,33 +477,6 @@ function SelectedEnvironmentProviderSettings({
496477 );
497478}
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
526480function RemoteSessionGatedProviderSettings({
527481 environment,
528482 deviceTabs,
......@@ -597,15 +551,15 @@ export function EnvironmentProviderSettings({
597551 readonly targetInstanceId?: ProviderInstanceId | undefined;
598552 /**
599553 * Grey out and freeze every write control when this session's credential
600 * lacks `orchestration:operate` on the environment. Selecting providers
554 * lacks `providers:manage` on the environment. Selecting providers
601555 * 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.
603557 */
604558 readonly readOnly?: boolean;
605559}) {
606560 const settings = useEnvironmentSettings(environmentId);
607 // Provider instances hold per-machine credentials and binaries, so this
608 // page always edits exactly the environment it displays.
561 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
562 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
609563 const updateSettings = useUpdateEnvironmentSettings(environmentId);
610564 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
611565 const updateClientSettings = useUpdateClientSettings();
......@@ -700,7 +654,8 @@ export function EnvironmentProviderSettings({
700654 : null;
701655
702656 const refreshProviders = useCallback(() => {
703 if (refreshingRef.current) return;
657 if (refreshingRef.current || !readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
658 return;
704659 refreshingRef.current = true;
705660 setIsRefreshingProviders(true);
706661 void (async () => {
......@@ -725,6 +680,7 @@ export function EnvironmentProviderSettings({
725680 candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">,
726681 targetVersion?: string,
727682 ) => {
683 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
728684 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
729685 // may run after this function returns, so it cannot gate the dispatch.
730686 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {
......@@ -1062,6 +1018,7 @@ export function EnvironmentProviderSettings({
10621018 />
10631019 ) : undefined
10641020 }
1021 canWriteSettings={canWriteSettings}
10651022 setup={
10661023 mode === "editor" && row.driver === "antigravity" ? (
10671024 <ProviderSetupSection
......@@ -1125,7 +1082,10 @@ export function EnvironmentProviderSettings({
11251082 onUpdate={(next) => {
11261083 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
11271084 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);
11291089 updateProviderInstance(
11301090 row,
11311091 next,
......@@ -1197,53 +1157,47 @@ export function EnvironmentProviderSettings({
11971157 headerAction={
11981158 <div className="flex min-w-0 items-center gap-2">
11991159 <ProviderUpdatesAction />
1200 {readOnly ? (
1201 <span className="min-w-0 truncate text-xs text-muted-foreground">
1202 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1203 </span>
1204 ) : (
1205 <>
1206 <Tooltip>
1207 <TooltipTrigger
1208 render={
1209 <Button
1210 size="xs"
1211 variant="ghost-muted"
1212 disabled={isRefreshingProviders}
1213 aria-busy={isRefreshingProviders}
1214 onClick={() => void refreshProviders()}
1215 >
1216 <RefreshIcon refreshing={isRefreshingProviders} />
1217 <span className="sr-only">Refresh provider status</span>
1218 <span className="hidden min-w-0 truncate sm:inline">
1219 {isRefreshingProviders ? (
1220 "Refreshing providers"
1221 ) : (
1222 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1223 )}
1224 </span>
1225 </Button>
1226 }
1227 />
1228 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1229 </Tooltip>
1230 <Tooltip>
1231 <TooltipTrigger
1232 render={
1233 <Button
1234 size="icon-xs"
1235 variant="ghost-muted"
1236 onClick={() => setIsAddInstanceDialogOpen(true)}
1237 aria-label="Add provider"
1238 >
1239 <PlusIcon />
1240 </Button>
1241 }
1242 />
1243 <TooltipPopup side="top">Add provider</TooltipPopup>
1244 </Tooltip>
1245 </>
1246 )}
1160 <Tooltip>
1161 <TooltipTrigger
1162 render={
1163 <Button
1164 size="xs"
1165 variant="ghost-muted"
1166 disabled={isRefreshingProviders || !canRefreshProviders}
1167 aria-busy={isRefreshingProviders}
1168 onClick={() => void refreshProviders()}
1169 >
1170 <RefreshIcon refreshing={isRefreshingProviders} />
1171 <span className="sr-only">Refresh provider status</span>
1172 <span className="hidden min-w-0 truncate sm:inline">
1173 {isRefreshingProviders ? (
1174 "Refreshing providers"
1175 ) : (
1176 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1177 )}
1178 </span>
1179 </Button>
1180 }
1181 />
1182 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1183 </Tooltip>
1184 {!readOnly ? (
1185 <Tooltip>
1186 <TooltipTrigger
1187 render={
1188 <Button
1189 size="icon-xs"
1190 variant="ghost-muted"
1191 onClick={() => setIsAddInstanceDialogOpen(true)}
1192 aria-label="Add provider"
1193 >
1194 <PlusIcon />
1195 </Button>
1196 }
1197 />
1198 <TooltipPopup side="top">Add provider</TooltipPopup>
1199 </Tooltip>
1200 ) : null}
12471201 </div>
12481202 }
12491203 >
......@@ -1318,7 +1272,10 @@ export function EnvironmentProviderSettings({
13181272 description="Refresh provider status, versions, and models in the background. Set to 0 to disable."
13191273 resetAction={
13201274 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
1321 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
1275 <span
1276 inert={!canWriteSettings}
1277 className={!canWriteSettings ? "opacity-50" : undefined}
1278 >
13221279 <SettingResetButton
13231280 label="provider health check interval"
13241281 onClick={() =>
......@@ -1336,11 +1293,11 @@ export function EnvironmentProviderSettings({
13361293 }
13371294 control={
13381295 <div
1339 inert={readOnly}
1340 aria-disabled={readOnly || undefined}
1296 inert={!canWriteSettings}
1297 aria-disabled={!canWriteSettings || undefined}
13411298 className={cn(
13421299 "flex shrink-0 items-center gap-2",
1343 readOnly && "opacity-50 select-none",
1300 !canWriteSettings && "opacity-50 select-none",
13441301 )}
13451302 >
13461303 <NumberField
......@@ -1375,7 +1332,7 @@ export function EnvironmentProviderSettings({
13751332 />
13761333 </SettingsSection>
13771334
1378 {isAddInstanceDialogOpen ? (
1335 {isAddInstanceDialogOpen && !readOnly ? (
13791336 <AddProviderInstanceDialog
13801337 open
13811338 environmentId={environmentId}
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+28-3
......@@ -1,4 +1,5 @@
11import { ProcessSignalActions } from "./ProcessSignalActions";
2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
23import { RefreshIcon } from "~/components/ui/refresh-icon";
34import {
45 ActivityIcon,
......@@ -40,6 +41,7 @@ import {
4041import { cn } from "../../lib/utils";
4142import { ensureLocalApi } from "../../localApi";
4243import { serverEnvironment } from "../../state/server";
44import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
4345import { useAtomCommand } from "../../state/use-atom-command";
4446import { formatRelativeTime } from "../../timestampFormat";
4547import { Button } from "../ui/button";
......@@ -520,10 +522,12 @@ function canSignalProcess(process: ResourceTelemetryProcess): boolean {
520522
521523function ProcessActions({
522524 process,
525 canMaintainEnvironment,
523526 signalingKeys,
524527 onSignal,
525528}: {
526529 process: ResourceTelemetryProcess;
530 canMaintainEnvironment: boolean;
527531 signalingKeys: ReadonlySet<string>;
528532 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
529533}) {
......@@ -532,16 +536,21 @@ function ProcessActions({
532536 }
533537 const isSignaling = signalingKeys.has(processIdentityKey(process));
534538 return (
535 <ProcessSignalActions disabled={isSignaling} onSignal={(signal) => onSignal(process, signal)} />
539 <ProcessSignalActions
540 disabled={!canMaintainEnvironment || isSignaling}
541 onSignal={(signal) => onSignal(process, signal)}
542 />
536543 );
537544}
538545
539546function ProcessTable({
540547 processes,
548 canMaintainEnvironment,
541549 signalingKeys,
542550 onSignal,
543551}: {
544552 processes: ReadonlyArray<ResourceTelemetryProcess>;
553 canMaintainEnvironment: boolean;
545554 signalingKeys: ReadonlySet<string>;
546555 onSignal: (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => void;
547556}) {
......@@ -650,6 +659,7 @@ function ProcessTable({
650659 <td className="px-2 py-2 text-right sm:pr-4">
651660 <ProcessActions
652661 process={process}
662 canMaintainEnvironment={canMaintainEnvironment}
653663 signalingKeys={signalingKeys}
654664 onSignal={onSignal}
655665 />
......@@ -820,6 +830,7 @@ export function ResourceTelemetryDiagnostics({
820830 const [windowMs, setWindowMs] = useState(15 * 60_000);
821831 const selectedWindow =
822832 HISTORY_WINDOWS.find((option) => option.windowMs === windowMs) ?? HISTORY_WINDOWS[1];
833 const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope);
823834 const telemetry = useResourceTelemetry(environmentId);
824835 const retryTelemetry = telemetry.retry;
825836 const history = useResourceTelemetryHistory(
......@@ -848,7 +859,11 @@ export function ResourceTelemetryDiagnostics({
848859 const signalProcess = useCallback(
849860 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
850861 const targetEnvironmentId = environmentIdRef.current;
851 if (targetEnvironmentId === null) return;
862 if (
863 targetEnvironmentId === null ||
864 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
865 )
866 return;
852867 const identityKey = processIdentityKey(process);
853868 if (signalingKeysRef.current.has(identityKey)) return;
854869 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
......@@ -882,7 +897,10 @@ export function ResourceTelemetryDiagnostics({
882897 return;
883898 }
884899 }
885 if (environmentIdRef.current !== targetEnvironmentId) {
900 if (
901 environmentIdRef.current !== targetEnvironmentId ||
902 !readEnvironmentScope(targetEnvironmentId, AuthEnvironmentMaintainScope)
903 ) {
886904 clearSignaling();
887905 return;
888906 }
......@@ -924,6 +942,12 @@ export function ResourceTelemetryDiagnostics({
924942 );
925943
926944 const retryCollector = useCallback(() => {
945 const environmentId = environmentIdRef.current;
946 if (
947 environmentId === null ||
948 !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)
949 )
950 return;
927951 setIsRetrying(true);
928952 void retryTelemetry()
929953 .catch((error: unknown) => {
......@@ -1253,6 +1277,7 @@ export function ResourceTelemetryDiagnostics({
12531277 <div className="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-xs/5">
12541278 <ProcessTable
12551279 processes={snapshot?.processes ?? []}
1280 canMaintainEnvironment={canMaintainEnvironment}
12561281 signalingKeys={signalingKeys}
12571282 onSignal={signalProcess}
12581283 />
apps/web/src/components/settings/ScheduledTasksSettings.tsx+21-6
......@@ -59,6 +59,8 @@ import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
5959import { useAtomCommand } from "../../state/use-atom-command";
6060import { WorktreeBaseBranchPicker } from "../WorktreeBaseBranchPicker";
6161import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
62import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
63import { readEnvironmentScope } from "~/state/session";
6264import { useSettingsScope } from "./SettingsScopeContext";
6365import {
6466 WEBHOOK_SIGNATURE_DEFAULTS,
......@@ -246,6 +248,9 @@ export function ScheduledTasksSettings(target: {
246248 setEditor({ environmentId, task });
247249 }, []);
248250 const defaultEnvironment = environment ?? connectedEnvironments[0];
251 const canCreate = useAtomValue(
252 serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null),
253 );
249254 return (
250255 <SettingsPageContainer>
251256 <SettingsSection
......@@ -255,7 +260,7 @@ export function ScheduledTasksSettings(target: {
255260 <Button
256261 size="xs"
257262 variant="ghost-muted"
258 disabled={!defaultEnvironment}
263 disabled={!defaultEnvironment || !canCreate}
259264 onClick={() =>
260265 defaultEnvironment &&
261266 setEditor({ environmentId: defaultEnvironment.environmentId, task: null })
......@@ -404,6 +409,9 @@ function ScheduledTaskRow({
404409 readonly task: ScheduledTask;
405410 readonly onEdit: () => void;
406411}) {
412 const canOperate = useAtomValue(
413 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
414 );
407415 const [busy, setBusy] = useState(false);
408416 const [deliveriesOpen, setDeliveriesOpen] = useState(false);
409417 const isWebhook = task.schedule.type === "webhook";
......@@ -417,7 +425,7 @@ function ScheduledTaskRow({
417425 label: "scheduled task delete",
418426 });
419427 const act = async (action: "toggle" | "run" | "delete") => {
420 if (busy) return;
428 if (busy || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
421429 setBusy(true);
422430 const result =
423431 action === "toggle"
......@@ -462,7 +470,7 @@ function ScheduledTaskRow({
462470 <div className="flex items-center gap-2">
463471 <Switch
464472 checked={task.enabled}
465 disabled={busy}
473 disabled={busy || !canOperate}
466474 aria-label={`Enable ${task.title}`}
467475 onCheckedChange={() => void act("toggle")}
468476 />
......@@ -472,7 +480,7 @@ function ScheduledTaskRow({
472480 <Button
473481 size="icon-sm"
474482 variant="ghost"
475 disabled={busy}
483 disabled={busy || !canOperate}
476484 aria-label={`Actions for ${task.title}`}
477485 />
478486 }
......@@ -668,6 +676,9 @@ function WebhookEndpointField({
668676 readonly environmentId: EnvironmentId;
669677 readonly task: ScheduledTask | null;
670678}) {
679 const canRotate = useAtomValue(
680 serverEnvironment.rotateScheduledTaskWebhookToken.permissionAtom(environmentId),
681 );
671682 const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId);
672683 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "webhook URL" });
673684 const rotate = useAtomCommand(serverEnvironment.rotateScheduledTaskWebhookToken, {
......@@ -725,7 +736,7 @@ function WebhookEndpointField({
725736 size="sm"
726737 variant="outline"
727738 type="button"
728 disabled={rotating}
739 disabled={rotating || !canRotate}
729740 onClick={() => void rotateUrl()}
730741 >
731742 Rotate
......@@ -787,6 +798,9 @@ function ScheduledTaskEditorDialog({
787798 const settings = useEnvironmentSettings(environmentId);
788799 const providers =
789800 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
801 const canOperate = useAtomValue(
802 serverEnvironment.upsertScheduledTask.permissionAtom(environmentId),
803 );
790804 const upsertTask = useAtomCommand(serverEnvironment.upsertScheduledTask, {
791805 label: "scheduled task upsert",
792806 });
......@@ -840,6 +854,7 @@ function ScheduledTaskEditorDialog({
840854
841855 const submit = async () => {
842856 if (
857 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
843858 submissionPending.current ||
844859 saving ||
845860 editingTaskMissing ||
......@@ -1348,7 +1363,7 @@ function ScheduledTaskEditorDialog({
13481363 </DialogClose>
13491364 <Button
13501365 size="sm"
1351 disabled={saving || editingTaskMissing || !connected || !tasksQuery.data}
1366 disabled={!canOperate || saving || editingTaskMissing || !connected || !tasksQuery.data}
13521367 onClick={() => void submit()}
13531368 >
13541369 {draft.editingId ? "Save task" : "Create task"}
apps/web/src/components/settings/SettingsPanels.tsx+9-3
......@@ -1,4 +1,5 @@
11import { SettingsGroup } from "./SettingsGroup";
2import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
23import { Spinner } from "~/components/ui/spinner";
34import { NotificationSettings } from "./NotificationSettings";
45import { PRIVACY_POLICY_URL } from "../../legalLinks";
......@@ -876,6 +877,7 @@ function BackgroundActivityAdvancedDialog({
876877 readonly open: boolean;
877878 readonly onOpenChange: (open: boolean) => void;
878879}) {
880 const canWriteSettings = useScopedSettingsWriteAllowed();
879881 const settings = useScopedSettings();
880882 const updateSettings = useUpdateScopedSettings();
881883 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
......@@ -894,7 +896,7 @@ function BackgroundActivityAdvancedDialog({
894896 );
895897
896898 return (
897 <Dialog open={open} onOpenChange={onOpenChange}>
899 <Dialog open={open && canWriteSettings} onOpenChange={onOpenChange}>
898900 <DialogPopup className="max-w-xl">
899901 <DialogHeader>
900902 <DialogTitle>Background Activity</DialogTitle>
......@@ -903,7 +905,10 @@ function BackgroundActivityAdvancedDialog({
903905 </DialogDescription>
904906 </DialogHeader>
905907 <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 >
907912 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
908913 <div className="min-w-0 space-y-1">
909914 <div className="text-sm font-medium">Shared policy</div>
......@@ -1125,11 +1130,12 @@ function BackgroundActivityAdvancedDialog({
11251130 </label>
11261131 ))}
11271132 </div>
1128 </div>
1133 </fieldset>
11291134 </DialogPanel>
11301135 <DialogFooter>
11311136 <Button
11321137 variant="outline"
1138 disabled={!canWriteSettings}
11331139 onClick={() => updateSettings(resetBackgroundActivitySettings())}
11341140 >
11351141 Reset all
apps/web/src/components/settings/SnapShotSettings.test.tsx+4
......@@ -25,6 +25,10 @@ vi.mock("react/compiler-runtime", async () => {
2525 return { c: reactHookHarness.useMemoCache };
2626});
2727vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] }));
28vi.mock("../../state/environments", () => ({
29 usePrimaryEnvironmentId: () => null,
30 useEnvironments: () => [],
31}));
2832vi.mock("../../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
2933vi.mock("./SettingsScopeContext", () => ({ useOptionalSettingsScope: () => null }));
3034vi.mock("./SettingsScopeSentence", () => ({ SettingsScopeSentence: () => null }));
apps/web/src/components/settings/settingsLayout.tsx+27-5
......@@ -1,4 +1,7 @@
11import { SettingsGroup } from "./SettingsGroup";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { usePrimaryEnvironmentId } from "../../state/environments";
4import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
25import { InfoIcon, Undo2Icon } from "lucide-react";
36import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
47import * as Equal from "effect/Equal";
......@@ -27,6 +30,7 @@ import { SettingsScopeSentence } from "./SettingsScopeSentence";
2730import {
2831 isProjectScopedSettingKey,
2932 listProjectOverrides,
33 type ProjectOverrideEntry,
3034 scopedSettingsAreMixed,
3135 scopedSettingsSource,
3236} from "./scopedSettings";
......@@ -281,6 +285,16 @@ export function SettingsRow({
281285 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
282286 const primarySettingsAvailable = usePrimarySettingsAvailable();
283287 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;
284298 const clearOverrides = useClearScopedSettings();
285299 const clearProjectOverrides = useClearProjectOverrides();
286300 const isProjectScope =
......@@ -295,7 +309,8 @@ export function SettingsRow({
295309 context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null;
296310 const unavailable =
297311 serverScoped &&
298 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable);
312 (!canWriteSettings ||
313 !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable));
299314 const inheritedFrom =
300315 source === "environment" && context?.scope.environmentIds.length === 1
301316 ? (context.environments.find(
......@@ -375,9 +390,11 @@ export function SettingsRow({
375390 const renderedControl =
376391 unavailable && control
377392 ? inertControl(
378 context
379 ? "Reconnect the selected environment to change this setting."
380 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
393 !canWriteSettings
394 ? "This connection does not have permission to change environment settings."
395 : context
396 ? "Reconnect the selected environment to change this setting."
397 : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
381398 )
382399 : environmentWide && control
383400 ? inertControl("Environment-wide setting. Select an environment to change it.")
......@@ -415,7 +432,12 @@ export function SettingsRow({
415432 environments={context.connectedEnvironments}
416433 keys={settingKeys}
417434 overridingProjects={overridingProjects}
418 onClearOverrides={(entries) => clearProjectOverrides(entries, scopedKeys)}
435 {...(canWriteSettings
436 ? {
437 onClearOverrides: (entries: readonly ProjectOverrideEntry[]) =>
438 clearProjectOverrides(entries, scopedKeys),
439 }
440 : {})}
419441 />
420442 ) : null;
421443 const renderedStatus = status;
apps/web/src/components/settings/settingsSearch.ts-1
......@@ -813,7 +813,6 @@ export const SETTINGS_SEARCH_ITEMS = [
813813 to: "/settings/connections",
814814 targetId: "connections-environment",
815815 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],
816 localBackendManagementOnly: true,
817816 },
818817 {
819818 id: "local-environment",
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+7-11
......@@ -1,14 +1,14 @@
11import { useMemo } from "react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";
2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
33
44import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
55import { hasCloudPublicConfig } from "~/cloud/publicConfig";
66import { isElectron } from "~/env";
77import { isLocalEnvironmentDisabled } from "~/localEnvironment";
88import { desktopWslStateAtom } from "~/state/desktopWslState";
9import { useEnvironments } from "~/state/environments";
9import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
1010import { useEnvironmentQuery } from "~/state/query";
11import { usePrimarySessionState } from "~/environments/primary";
11import { useEnvironmentScope } from "~/state/session";
1212import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1313import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
1414import type { SettingsScopeSearch } from "./settingsScope";
......@@ -19,21 +19,17 @@ import {
1919
2020export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch = {}) {
2121 const { environments } = useEnvironments();
22 const primarySessionState = usePrimarySessionState();
22 const primaryEnvironmentId = usePrimaryEnvironmentId();
2323 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
24 const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope);
25 const canManageLocalBackend = !localEnvironmentDisabled && canMaintain;
2426 const desktopWsl = useEnvironmentQuery(
25 isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null,
27 isElectron && canManageLocalBackend ? desktopWslStateAtom : null,
2628 );
2729 const cloudLinkState = usePrimaryCloudLinkState().data;
2830 // Same fallback as the Connections row: older servers imply a tunnel from `linked`.
2931 const managedTunnelActive =
3032 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
3834 return useMemo(
3935 () =>
apps/web/src/components/settings/useHostConnectionChecks.ts+5
......@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
13import { useRef, useState } from "react";
24import * as Cause from "effect/Cause";
35import type { SshDeviceHostConfig } from "@t3tools/contracts";
......@@ -25,6 +27,9 @@ export function useHostConnectionChecks(targets: ReadonlyArray<DeviceHostCheckTa
2527 targets,
2628 host,
2729 async (environmentId, input) => {
30 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
31 throw new Error("This connection cannot test device hosts.");
32 }
2833 const result = await test({ environmentId, input });
2934 if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause));
3035 return result.value;
apps/web/src/components/settings/useProjectScriptSettings.ts+18
......@@ -1,3 +1,5 @@
1import { AuthSettingsWriteScope, EnvironmentAuthorizationError } from "@t3tools/contracts";
2import { readEnvironmentScope } from "../../state/session";
13import {
24 isAtomCommandInterrupted,
35 mapAtomCommandResult,
......@@ -80,6 +82,22 @@ export function useProjectScriptSettings(
8082 toastManager.add({ type: "error", title: "Actions not saved", description: message });
8183 return AsyncResult.failure(Cause.fail(new Error(message)));
8284 }
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 }
83101 savingRef.current = true;
84102 setSaving(true);
85103 try {
apps/web/src/components/settings/useScopedSettings.ts+44-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";
110import {
211 DEFAULT_SERVER_SETTINGS,
312 type ProjectScopedServerSettingKey,
......@@ -63,19 +72,33 @@ function useRunScopedPlan() {
6372 });
6473 return;
6574 }
66 void persistScopedSettingsPatch(plan, persistServer, persistClientSettingsPatch).then(
67 ({ failedEnvironments, savedEnvironmentCount }) => {
68 if (failedEnvironments.length === 0) return;
69 toastManager.add({
70 type: "error",
71 title:
72 savedEnvironmentCount > 0
73 ? "Setting saved on some environments"
74 : "Setting not saved",
75 description: `Could not update ${failedEnvironments.map((environment) => environment.label).join(", ")}.${savedEnvironmentCount > 0 ? " The other selected environments saved the change." : ""}`,
76 });
75 void persistScopedSettingsPatch(
76 plan,
77 async (request) => {
78 const missing = requiredScopesForServerSettingsPatch(request.input.patch).find(
79 (scope) => !readEnvironmentScope(request.environmentId, scope),
80 );
81 if (missing)
82 return AsyncResult.failure(
83 Cause.fail(
84 new EnvironmentAuthorizationError({
85 requiredScope: missing,
86 message: "This connection cannot change these settings.",
87 }),
88 ),
89 );
90 return persistServer(request);
7791 },
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 });
79102 },
80103 [persistServer],
81104 );
......@@ -119,3 +142,12 @@ export function useClearProjectOverrides() {
119142 [context, run],
120143 );
121144}
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 @@
11import {
2 AuthProvidersManageScope,
23 type EnvironmentId,
34 type ProviderConsumeResetCreditOutcome,
45 ProviderConsumeResetCreditInput,
......@@ -21,6 +22,7 @@ import { Fragment, type ReactNode, useState } from "react";
2122
2223import { usePrimarySettings } from "../../hooks/useSettings";
2324import { environmentPresentations } from "../../state/presentation";
25import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
2426import { serverEnvironment } from "../../state/server";
2527import { useAtomCommand } from "../../state/use-atom-command";
2628import { formatUpcomingTimestamp } from "../../timestampFormat";
......@@ -206,6 +208,7 @@ export function useResetCredit(
206208 environmentId: EnvironmentId,
207209 input: ProviderConsumeResetCreditInput,
208210) {
211 const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
209212 const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false });
210213 const [confirming, setConfirming] = useState(false);
211214 const [busy, setBusy] = useState(false);
......@@ -213,6 +216,7 @@ export function useResetCredit(
213216
214217 const redeem = async () => {
215218 setConfirming(false);
219 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
216220 setBusy(true);
217221 setStatus(null);
218222 const result = await consume({ environmentId, input });
......@@ -228,7 +232,7 @@ export function useResetCredit(
228232 );
229233 };
230234
231 return { confirming, setConfirming, busy, status, redeem };
235 return { canManageProviders, confirming, setConfirming, busy, status, redeem };
232236}
233237
234238/**
......@@ -241,10 +245,12 @@ export function ResetCreditDialog({
241245 open,
242246 onOpenChange,
243247 onConfirm,
248 disabled = false,
244249}: {
245250 readonly open: boolean;
246251 readonly onOpenChange: (open: boolean) => void;
247252 readonly onConfirm: () => void;
253 readonly disabled?: boolean;
248254}) {
249255 return (
250256 <AlertDialog open={open} onOpenChange={onOpenChange}>
......@@ -258,7 +264,9 @@ export function ResetCreditDialog({
258264 </AlertDialogHeader>
259265 <AlertDialogFooter>
260266 <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>
262270 </AlertDialogFooter>
263271 </AlertDialogPopup>
264272 </AlertDialog>
......@@ -294,13 +302,23 @@ export function ResetCredits({
294302 readonly credits: ServerProviderResetCredits;
295303 readonly now: number;
296304}) {
297 const { confirming, setConfirming, busy, status, redeem } = useResetCredit(environmentId, input);
305 const { canManageProviders, confirming, setConfirming, busy, status, redeem } = useResetCredit(
306 environmentId,
307 input,
308 );
298309 if (credits.availableCount === 0 && status === null) return null;
299310 return (
300311 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
301312 <span className="tabular-nums">{resetCreditsSummary(credits, now)}</span>
302313 {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 >
304322 {busy ? "Using…" : "Use reset"}
305323 </Button>
306324 ) : null}
......@@ -309,6 +327,7 @@ export function ResetCredits({
309327 open={confirming}
310328 onOpenChange={setConfirming}
311329 onConfirm={() => void redeem()}
330 disabled={!canManageProviders}
312331 />
313332 </div>
314333 );
apps/web/src/components/usage/UsageLimitsPooled.tsx+2-1
......@@ -204,7 +204,7 @@ function SegmentPopover({
204204 <Button
205205 size="xs"
206206 variant="outline"
207 disabled={redeem.busy}
207 disabled={redeem.busy || !redeem.canManageProviders}
208208 className="ms-auto"
209209 onClick={onRedeem}
210210 >
......@@ -429,6 +429,7 @@ function RedeemableSegmentPopup({
429429 open={redeem.confirming}
430430 onOpenChange={redeem.setConfirming}
431431 onConfirm={() => void redeem.redeem()}
432 disabled={!redeem.canManageProviders}
432433 />
433434 {/* The popover closed before the confirm, so the outcome needs a home outside it. */}
434435 {redeem.status ? (
apps/web/src/components/usage/UsagePriceOverrides.tsx+17-17
......@@ -1,21 +1,16 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
2import { AuthSettingsWriteScope, type EnvironmentId } from "@t3tools/contracts";
33import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react";
44import * as Option from "effect/Option";
55import { AsyncResult, Atom } from "effect/reactivity";
66import { useRef, useState } from "react";
77
8import { isElectron } from "../../env";
98import { cn } from "../../lib/utils";
109import { environmentPresentations } from "../../state/presentation";
1110import { serverEnvironment } from "../../state/server";
12import { environmentSession } from "../../state/session";
11import { environmentSession, readEnvironmentScope } from "../../state/session";
1312import { useAtomCommand } from "../../state/use-atom-command";
1413import type { EnvironmentUsageStatus } from "../../state/usage";
15import {
16 resolvePrimaryOperateAccess,
17 resolveRemoteOperateAccess,
18} from "../settings/ProviderSettingsPanel.logic";
1914import { Button } from "../ui/button";
2015import {
2116 Dialog,
......@@ -51,15 +46,17 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] =>
5146 [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => {
5247 const settings = get(serverEnvironment.settingsValueAtom(environmentId));
5348 const session = get(environmentSession.sessionStateAtom(environmentId));
54 const sessionAccess = {
55 session: Option.getOrNull(AsyncResult.value(session)),
56 isPending: session.waiting,
57 hasError: session._tag === "Failure",
58 };
59 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
60 const access = isPrimary
61 ? resolvePrimaryOperateAccess({ ...sessionAccess, isPrimary, hasDesktopBridge: isElectron })
62 : resolveRemoteOperateAccess(sessionAccess);
49 const sessionData = Option.getOrNull(AsyncResult.value(session));
50 const access =
51 session._tag === "Failure"
52 ? "denied"
53 : sessionData === null
54 ? session.waiting
55 ? "pending"
56 : "denied"
57 : sessionData.authenticated && sessionData.scopes?.includes(AuthSettingsWriteScope)
58 ? "granted"
59 : "denied";
6360 return {
6461 environmentId,
6562 label: environment.entry.target.label,
......@@ -262,7 +259,10 @@ export function UsagePriceOverrides({
262259 target.unavailable ?? [...changes.get(target.environmentId)!.errors.values()][0] ?? null,
263260 })),
264261 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" },
266266 onResult: (environmentId, result) => {
267267 if (result.status === "failed") failed = true;
268268 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 @@
1import { EDITORS, EditorId, EnvironmentId } from "@t3tools/contracts";
1import {
2 AuthOrchestrationOperateScope,
3 EDITORS,
4 EditorId,
5 EnvironmentAuthorizationError,
6 EnvironmentId,
7} from "@t3tools/contracts";
28import {
39 mapAtomCommandResult,
410 type AtomCommandFailure,
......@@ -11,6 +17,7 @@ import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hoo
1117import { useCallback, useMemo } from "react";
1218import { shellEnvironment } from "./state/shell";
1319import { useAtomCommand } from "./state/use-atom-command";
20import { readEnvironmentScope } from "./state/session";
1421
1522const LAST_EDITOR_KEY = "t3code:last-editor";
1623
......@@ -49,9 +56,7 @@ export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {
4956 return [effectiveEditor, setLastEditor] as const;
5057}
5158
52export function resolveAndPersistPreferredEditor(
53 availableEditors: readonly EditorId[],
54): EditorId | null {
59function resolveAndPersistPreferredEditor(availableEditors: readonly EditorId[]): EditorId | null {
5560 const availableEditorIds = new Set(availableEditors);
5661 const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId);
5762 if (stored && availableEditorIds.has(stored)) return stored;
......@@ -78,6 +83,7 @@ export function useOpenInPreferredEditor(
7883 | OpenInEditorError
7984 | PreferredEditorEnvironmentRequiredError
8085 | PreferredEditorUnavailableError
86 | EnvironmentAuthorizationError
8187 >
8288 > => {
8389 if (environmentId === null) {
......@@ -89,6 +95,16 @@ export function useOpenInPreferredEditor(
8995 ),
9096 );
9197 }
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 }
92108 const editor = resolveAndPersistPreferredEditor(availableEditors);
93109 if (!editor) {
94110 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";
1313import { useAtomValue } from "@effect/atom-react";
1414import {
1515 DEFAULT_SERVER_SETTINGS,
16 AuthSettingsWriteScope,
17 requiredScopesForServerSettingsPatch,
1618 type EnvironmentId,
1719 type ProviderInstanceMutation,
1820 ServerSettings,
......@@ -40,12 +42,16 @@ import {
4042 themeAllowsSidebarArtwork,
4143} from "~/themePalette";
4244import * as Struct from "effect/Struct";
45import * as Option from "effect/Option";
46import { AsyncResult, Atom } from "effect/reactivity";
4347import { toastManager } from "~/components/ui/toast";
4448import { isHostedStaticApp } from "~/hostedPairing";
4549import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
4650import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
4751import { useAtomCommand } from "~/state/use-atom-command";
4852import { useTheme } from "./useTheme";
53import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session";
54import { appAtomRegistry } from "~/rpc/atomRegistry";
4955
5056const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
5157
......@@ -425,6 +431,31 @@ export function usePrimarySettingsAvailable(): boolean {
425431 return primaryEnvironment !== null || !isHostedStaticApp();
426432}
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
428459/**
429460 * Returns an updater that routes each key to the correct backing store.
430461 *
......@@ -435,16 +466,31 @@ export function usePrimarySettingsAvailable(): boolean {
435466 * through client persistence.
436467 */
437468function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
469 // Mount this session even on pages without a visible permission-gated control.
470 useEnvironmentScope(environmentId, AuthSettingsWriteScope);
438471 const persistServerSettings = useAtomCommand(
439472 serverEnvironment.updateSettings,
440473 "server settings update",
441474 );
442475 const { environments } = useEnvironments();
476 const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true);
443477 const updateSettings = useCallback(
444478 (patch: UnifiedSettingsPatch) => {
445479 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) {
448494 const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch);
449495 // Dropping the write silently leaves the control looking saved.
450496 const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) =>
......@@ -467,13 +513,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
467513 const sourceSettings = environments.find(
468514 (target) => target.environmentId === environmentId,
469515 )?.serverConfig?.settings;
470 const targets = new Set(
471 environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),
472 );
516 const targets = new Set(sharedSettingsSyncTargetIds);
473517 if (environmentId) {
474518 targets.add(environmentId);
475519 }
476520 let wroteToTarget = false;
521 let permissionDenied = false;
477522 for (const targetId of targets) {
478523 const target = environments.find((candidate) => candidate.environmentId === targetId);
479524 const targetPatch = filterSharedServerPatch(
......@@ -484,6 +529,16 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
484529 targetId === environmentId,
485530 );
486531 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 }
487542 wroteToTarget = true;
488543 void persistServerSettings({
489544 environmentId: targetId,
......@@ -492,7 +547,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
492547 }
493548 if (!wroteToTarget) {
494549 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,
496555 );
497556 }
498557 }
......@@ -501,7 +560,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
501560 void persistClientSettingsPatch(clientPatch);
502561 }
503562 },
504 [environmentId, environments, persistServerSettings],
563 [environmentId, environments, persistServerSettings, sharedSettingsSyncTargetIds],
505564 );
506565
507566 return updateSettings;
apps/web/src/keybindings.test.ts+97
......@@ -25,6 +25,7 @@ import {
2525 isTerminalSplitShortcut,
2626 isTerminalSplitVerticalShortcut,
2727 isTerminalToggleShortcut,
28 resolveChatShortcutCommand,
2829 resolveShortcutCommand,
2930 shouldShowThreadJumpHintsForModifiers,
3031 shortcutLabelForCommand,
......@@ -886,6 +887,102 @@ describe("cross-command precedence", () => {
886887 });
887888});
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
889986describe("resolveShortcutCommand", () => {
890987 it("resolves a custom stop-thread shortcut", () => {
891988 const keybindings = compile([{ shortcut: modShortcut("escape"), command: "thread.stop" }]);
apps/web/src/keybindings.ts+19
......@@ -18,6 +18,7 @@ import {
1818 type ShortcutModifierStateLike,
1919} from "@t3tools/shared/keybindings";
2020import { isElectron } from "./env";
21import { projectScriptIdFromCommand } from "./projectScripts";
2122
2223export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
2324export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
......@@ -169,6 +170,24 @@ export function resolveShortcutCommand(
169170 return null;
170171}
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
172191export function formatShortcutKeyLabel(key: string): string {
173192 if (key === " ") return "Space";
174193 if (key.length === 1) return key.toUpperCase();
apps/web/src/lib/attachmentUploadQueue.test.ts+111
......@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
1818 executeAtomQuery: vi.fn(),
1919 removeUpload: Symbol("remove-upload"),
2020 runAtomCommand: vi.fn(),
21 readEnvironmentScope: vi.fn(),
2122 readPreparedConnection: vi.fn(),
2223}));
2324
......@@ -48,6 +49,7 @@ vi.mock("../state/attachments", () => ({
4849}));
4950
5051vi.mock("../state/session", () => ({
52 readEnvironmentScope: mocks.readEnvironmentScope,
5153 readPreparedConnection: mocks.readPreparedConnection,
5254}));
5355
......@@ -172,6 +174,7 @@ describe("attachmentUploadQueue", () => {
172174 mocks.executeAtomQuery.mockReset();
173175 mocks.executeAtomQuery.mockResolvedValue({ _tag: "Success", value: {} });
174176 mocks.runAtomCommand.mockReset();
177 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
175178 mocks.readPreparedConnection.mockReset();
176179 mocks.readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://environment.test/" });
177180 mocks.runAtomCommand.mockImplementation(
......@@ -266,6 +269,114 @@ describe("attachmentUploadQueue", () => {
266269 expect(readAttachmentUpload(image.id)).toBeUndefined();
267270 });
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
269380 it("uploads images immediately and sends attachment references", async () => {
270381 const image = {
271382 ...makeImage("image-1"),
apps/web/src/lib/attachmentUploadQueue.ts+33-3
......@@ -1,4 +1,5 @@
11import {
2 AuthOrchestrationOperateScope,
23 PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES,
34 type ChatAttachment,
45 type EnvironmentId,
......@@ -26,7 +27,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
2627import { environmentCatalog } from "../connection/catalog";
2728import { assetEnvironment } from "../state/assets";
2829import { attachmentEnvironment } from "../state/attachments";
29import { readPreparedConnection } from "../state/session";
30import { readEnvironmentScope, readPreparedConnection } from "../state/session";
3031import type { AttachmentUploadState, ReadyAttachmentUpload } from "./attachmentUploadState";
3132
3233const MAX_UPLOADS_PER_ENVIRONMENT = 3;
......@@ -138,6 +139,7 @@ function stampDraftFileUpload(job: UploadJob, attachmentId: string): void {
138139}
139140
140141function deletePendingUpload(environmentId: EnvironmentId, attachmentId: string): void {
142 if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return;
141143 deletePendingAttachmentUpload({
142144 registry: appAtomRegistry,
143145 remove: attachmentEnvironment.remove,
......@@ -179,6 +181,15 @@ function uploadBytes(input: {
179181}
180182
181183async 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 }
182193 if (job.persistedAttachmentId) {
183194 const verification = await verifyPersistedAttachmentUpload({
184195 registry: appAtomRegistry,
......@@ -260,6 +271,15 @@ async function runUpload(job: UploadJob): Promise<void> {
260271 }
261272
262273 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 }
263283 const result = await runAttachmentUploadCycle({
264284 registry: appAtomRegistry,
265285 createUploadUrl: attachmentEnvironment.createUploadUrl,
......@@ -272,6 +292,12 @@ async function runUpload(job: UploadJob): Promise<void> {
272292 sizeBytes: file.size,
273293 },
274294 resolveUploadUrl: (relativeUrl) => {
295 if (
296 job.cancelled ||
297 !readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)
298 ) {
299 return null;
300 }
275301 const connection = readPreparedConnection(job.environmentId);
276302 return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;
277303 },
......@@ -295,7 +321,7 @@ async function runUpload(job: UploadJob): Promise<void> {
295321 },
296322 }),
297323 onMinted: (attachmentId) => {
298 if (job.cancelled) {
324 if (job.cancelled && readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)) {
299325 return "cancel";
300326 }
301327 job.attachmentId = attachmentId;
......@@ -328,7 +354,9 @@ async function runUpload(job: UploadJob): Promise<void> {
328354 result.step === "mint"
329355 ? "Upload could not start"
330356 : result.step === "resolve-url"
331 ? "Not connected"
357 ? readEnvironmentScope(job.environmentId, AuthOrchestrationOperateScope)
358 ? "Not connected"
359 : "This connection cannot upload attachments."
332360 : result.error instanceof Error
333361 ? result.error.message
334362 : "Upload failed",
......@@ -388,6 +416,7 @@ export function startAttachmentUpload(input: {
388416 /** Draft that owns the file; lets a background completion persist its ids. */
389417 readonly draftTarget?: ComposerThreadTarget;
390418}): void {
419 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return;
391420 const existingJob = jobsByImageId.get(input.image.id);
392421 if (existingJob?.environmentId === input.environmentId) {
393422 return;
......@@ -545,6 +574,7 @@ export function retryAttachmentUpload(input: {
545574 readonly image: ComposerImageAttachment | ComposerFileAttachment;
546575 readonly draftTarget?: ComposerThreadTarget;
547576}): void {
577 if (!readEnvironmentScope(input.environmentId, AuthOrchestrationOperateScope)) return;
548578 const previous = readAttachmentUpload(input.image.id);
549579 cancelAttachmentUpload(input.image.id);
550580 // A failed state's `attachmentId` is always one this queue minted, so this
apps/web/src/lib/resourceTelemetryState.ts+5
......@@ -1,3 +1,4 @@
1import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
12import type {
23 EnvironmentId,
34 ResourceTelemetryHistoryInput,
......@@ -9,6 +10,7 @@ import { useCallback } from "react";
910import { usePrimaryEnvironment } from "../state/environments";
1011import { useEnvironmentQuery } from "../state/query";
1112import { serverEnvironment } from "../state/server";
13import { readEnvironmentScope } from "../state/session";
1214import { useAtomCommand } from "../state/use-atom-command";
1315
1416export interface ResourceTelemetryState {
......@@ -39,6 +41,9 @@ export function useResourceTelemetry(
3941 if (environmentId === null) {
4042 throw new Error("No environment is selected.");
4143 }
44 if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) {
45 throw new Error("This connection cannot restart the resource monitor.");
46 }
4247 const result = await retryCommand({ environmentId, input: {} });
4348 if (result._tag === "Failure") {
4449 throw Cause.squash(result.cause);
apps/web/src/routes/__root.tsx+9-43
......@@ -1,6 +1,5 @@
11import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
22import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
43import {
54 Outlet,
65 Link,
......@@ -20,6 +19,7 @@ import { AppSidebarLayout } from "../components/AppSidebarLayout";
2019import { CommandPalette } from "../components/CommandPalette";
2120import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog";
2221import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
22import { KeybindingsConfigWarning } from "../components/KeybindingsConfigWarning";
2323import { FirstRunGate } from "../components/onboarding/FirstRunGate";
2424import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
2525import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
......@@ -49,7 +49,6 @@ import {
4949 ToastProvider,
5050 toastManager,
5151} from "../components/ui/toast";
52import { resolveAndPersistPreferredEditor } from "../editorPreferences";
5352import { isElectron } from "../env";
5453import { cn } from "../lib/utils";
5554import { applyAppearanceFontVariables } from "~/appearanceFonts";
......@@ -66,9 +65,7 @@ import { configureClientTracing } from "../observability/clientTracing";
6665import { resolveInitialServerAuthGateState } from "../environments/primary";
6766import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
6867import { isLocalEnvironmentDisabled } from "../localEnvironment";
69import { shellEnvironment } from "../state/shell";
7068import { useAtomValue } from "@effect/atom-react";
71import { useAtomCommand } from "../state/use-atom-command";
7269import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
7370import {
7471 primaryServerConfigAtom,
......@@ -496,9 +493,6 @@ function EventRouter({
496493 const pathname = useLocation({ select: (loc) => loc.pathname });
497494 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
498495 const primaryEnvironment = usePrimaryEnvironment();
499 const openInEditor = useAtomCommand(shellEnvironment.openInEditor, {
500 reportFailure: false,
501 });
502496 const serverConfig = useAtomValue(primaryServerConfigAtom);
503497 const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom);
504498 const serverWelcome = useAtomValue(primaryServerWelcomeAtom);
......@@ -575,42 +569,14 @@ function EventRouter({
575569 stackedThreadToast({
576570 type: "warning",
577571 title: "Invalid keybindings configuration",
578 description: decision.message,
579 actionVariant: "outline",
580 actionProps: {
581 children: "Open keybindings.json",
582 onClick: () => {
583 if (!serverConfig || !primaryEnvironment) {
584 return;
585 }
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 },
572 description: (
573 <KeybindingsConfigWarning
574 message={decision.message}
575 environmentId={primaryEnvironment?.environmentId ?? null}
576 configPath={serverConfig?.keybindingsConfigPath ?? null}
577 availableEditors={serverConfig?.availableEditors ?? []}
578 />
579 ),
614580 }),
615581 );
616582 });
apps/web/src/state/session.ts+62-1
......@@ -1,6 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
45import * as Option from "effect/Option";
56import { AsyncResult, Atom } from "effect/reactivity";
67
......@@ -9,6 +10,66 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
910
1011export 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
1273const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
1374 Atom.withLabel("web-prepared-connection:empty"),
1475);
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
5858scope](../../apps/server/src/auth/RpcAuthorization.ts), and the WebSocket RPC
5959group'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
6166Desktop restarts forget the previous local bearer token, so its reusable
6267bootstrap grant replaces earlier sessions for the same subject and method.
6368Revocation 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
277277the same environment replaces its saved grant. Reconnecting alone does not change
278278permissions.
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
280290To remove an environment from T3 Connect, open your account menu's **T3 Connect**
281291page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This
282292revokes 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
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010import noRawMcpRegistration from "./rules/no-raw-mcp-registration.ts";
1111import noTestInLoop from "./rules/no-test-in-loop.ts";
12import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts";
1213import noUnscopedHas from "./rules/no-unscoped-has.ts";
1314import preferCatchTags from "./rules/prefer-catch-tags.ts";
1415import requireSuppressionReason from "./rules/require-suppression-reason.ts";
......@@ -27,6 +28,7 @@ export default definePlugin({
2728 "no-native-title-tooltip": noNativeTitleTooltip,
2829 "no-raw-mcp-registration": noRawMcpRegistration,
2930 "no-test-in-loop": noTestInLoop,
31 "no-rpc-permission-bypass": noRpcPermissionBypass,
3032 "no-unscoped-has": noUnscopedHas,
3133 "prefer-catch-tags": preferCatchTags,
3234 "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 @@
11import {
22 EnvironmentAuthorizationError,
3 clientRpcRequiredScopes,
4 type EnvironmentId,
5 type ClientGuardedRpcTag,
36 ORCHESTRATION_V2_WS_METHODS,
47 WS_METHODS,
58} from "@t3tools/contracts";
......@@ -146,7 +149,40 @@ export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServer
146149 },
147150);
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* <
150186 TTag extends EnvironmentUnaryRpcTag,
151187>(tag: TTag, input: EnvironmentRpcInput<TTag>) {
152188 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
......@@ -155,6 +191,7 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <
155191 "rpc.method": tag,
156192 });
157193 const session = yield* currentSession();
194 yield* authorizeRequest(tag, input);
158195 const observer = yield* EnvironmentRpcRequestObserver;
159196 const method = session.client[tag] as (
160197 input: EnvironmentRpcInput<TTag>,
......@@ -166,16 +203,19 @@ export const request = Effect.fn("EnvironmentRpc.request")(function* <
166203 return yield* method(input).pipe(Effect.ensuring(completeObservation));
167204});
168205
169export function runStream<TTag extends EnvironmentStreamCommandRpcTag>(
206function runStreamGuarded<TTag extends EnvironmentStreamCommandRpcTag>(
170207 tag: TTag,
171208 input: EnvironmentRpcInput<TTag>,
172209): Stream.Stream<
173210 EnvironmentRpcStreamValue<TTag>,
174 EnvironmentRpcStreamFailure<TTag> | EnvironmentRpcUnavailableError,
211 | EnvironmentRpcStreamFailure<TTag>
212 | EnvironmentRpcUnavailableError
213 | EnvironmentAuthorizationError,
175214 EnvironmentSupervisor.EnvironmentSupervisor
176215> {
177216 return Stream.unwrap(
178 currentSession().pipe(
217 authorizeRequest(tag, input).pipe(
218 Effect.andThen(currentSession()),
179219 Effect.map((session) => {
180220 const method = session.client[tag] as (
181221 input: EnvironmentRpcInput<TTag>,
......@@ -393,3 +433,16 @@ export function subscribe<TTag extends EnvironmentSubscriptionRpcTag>(
393433> {
394434 return subscribeDynamic(tag, () => Effect.succeed(input), options);
395435}
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 @@
11export * from "./client.ts";
22export * from "./http.ts";
3export * from "./protocol.ts";
3export type { WsRpcProtocolClient } from "./protocol.ts";
44export { 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";
2424import * as ConnectionProfileStore from "../connection/profileStore.ts";
2525import type { RpcSession } from "../rpc/session.ts";
2626import {
27 request,
27 requestGuarded as request,
2828 EnvironmentRpcUnavailableError,
2929 type EnvironmentRpcInput,
3030 type EnvironmentUnaryRpcTag,
packages/client-runtime/src/state/runtime.ts+30-34
......@@ -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";
29import * as Cause from "effect/Cause";
310import * as Effect from "effect/Effect";
411import * as Exit from "effect/Exit";
......@@ -19,6 +26,8 @@ import {
1926 type EnvironmentUnaryRpcTag,
2027 EnvironmentRpcUnavailableError,
2128 request,
29 requestGuarded,
30 RpcPermissionGuard,
2231 subscribe,
2332} from "../rpc/client.ts";
2433import * as EnvironmentSupervisor from "../connection/supervisor.ts";
......@@ -459,36 +468,6 @@ function runInEnvironment<A, E, R>(
459468 );
460469}
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
492471/**
493472 * Refreshes a query when `signal` changes, but only while something reads it.
494473 * `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>(
660639 });
661640}
662641
663export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends EnvironmentUnaryRpcTag>(
642export function createEnvironmentRpcQueryAtomFamily<
643 R,
644 ER,
645 TTag extends Exclude<EnvironmentUnaryRpcTag, ClientGuardedRpcTag>,
646>(
664647 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,
665648 options: {
666649 readonly label: string;
......@@ -767,7 +750,8 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
767750 ) => Effect.Effect<void, never, R>;
768751 },
769752) {
770 return createEnvironmentCommand(runtime, {
753 const permissions = createCommandPermissions(runtime, options.tag);
754 const command = createEnvironmentCommand(runtime, {
771755 label: options.label,
772756 ...(options.scheduler === undefined ? {} : { scheduler: options.scheduler }),
773757 ...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
......@@ -776,10 +760,22 @@ export function createEnvironmentRpcCommand<R, ER, TTag extends EnvironmentUnary
776760 environmentId,
777761 input,
778762 };
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 }),
780771 Effect.tap(() => options.onSuccess?.(target, registry) ?? Effect.void),
781772 Effect.ensuring(options.onSettled?.(target, registry) ?? Effect.void),
782773 );
783774 },
784775 });
776 return {
777 ...command,
778 requiredScopes: permissions.requiredScopes,
779 permissionAtom: permissions.permissionAtom,
780 };
785781}
packages/client-runtime/src/state/session.ts+30-5
......@@ -1,9 +1,10 @@
11import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
2import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";
34import * as Option from "effect/Option";
45import * as Stream from "effect/Stream";
56import * as SubscriptionRef from "effect/SubscriptionRef";
6import type { HttpClient } from "effect/http";
7import { HttpClient } from "effect/http";
78import { AsyncResult, Atom } from "effect/reactivity";
89
910import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
......@@ -14,7 +15,12 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts";
1415import * as ManagedRelay from "../relay/managedRelay.ts";
1516import { safeErrorLogAttributes } from "../errors/safeLog.ts";
1617import { 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
1925function initialConfigOption<E>(
2026 initialConfig: Effect.Effect<ServerConfig, E>,
......@@ -61,8 +67,8 @@ export const fetchEnvironmentSessionState = Effect.fn(
6167 });
6268});
6369
64export function createEnvironmentSessionAtoms<R, E>(
65 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | HttpClient.HttpClient | R, E>,
70function makeEnvironmentSessionAtoms<R, E>(
71 runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, E>,
6672) {
6773 const initialConfigAtom = Atom.family((environmentId: EnvironmentId) =>
6874 runtime.atom(
......@@ -137,7 +143,13 @@ export function createEnvironmentSessionAtoms<R, E>(
137143 const remoteAuthorization = yield* Effect.serviceOption(
138144 RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization,
139145 );
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));
141153 });
142154 })
143155 .pipe(
......@@ -163,3 +175,16 @@ export function createEnvironmentSessionAtoms<R, E>(
163175 sessionStateValueAtom,
164176 };
165177}
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"]) {
7575 get(environmentId).query,
7676 refreshUsageRates: {
7777 label: "test:rates",
78 requiredScopes: () => [],
79 permissionAtom: () => Atom.make(true),
7880 run: (
7981 _registry: AtomRegistry.AtomRegistry,
8082 { environmentId }: { environmentId: EnvironmentId },
packages/contracts/src/auth.ts+11
......@@ -80,6 +80,9 @@ export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
8080
8181export const AuthOrchestrationReadScope = "orchestration:read" as const;
8282export 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;
8386export const AuthTerminalOperateScope = "terminal:operate" as const;
8487export const AuthReviewWriteScope = "review:write" as const;
8588export const AuthAccessReadScope = "access:read" as const;
......@@ -89,6 +92,9 @@ export const AuthRelayWriteScope = "relay:write" as const;
8992export const AuthEnvironmentScope = Schema.Literals([
9093 AuthOrchestrationReadScope,
9194 AuthOrchestrationOperateScope,
95 AuthSettingsWriteScope,
96 AuthProvidersManageScope,
97 AuthEnvironmentMaintainScope,
9298 AuthTerminalOperateScope,
9399 AuthReviewWriteScope,
94100 AuthAccessReadScope,
......@@ -103,6 +109,9 @@ export type AuthEnvironmentScopes = typeof AuthEnvironmentScopes.Type;
103109export const AuthStandardClientScopes = [
104110 AuthOrchestrationReadScope,
105111 AuthOrchestrationOperateScope,
112 AuthSettingsWriteScope,
113 AuthProvidersManageScope,
114 AuthEnvironmentMaintainScope,
106115 AuthTerminalOperateScope,
107116 AuthReviewWriteScope,
108117 AuthRelayReadScope,
......@@ -145,6 +154,8 @@ export const ServerAuthDescriptor = Schema.Struct({
145154 bootstrapMethods: Schema.Array(ServerAuthBootstrapMethod),
146155 sessionMethods: Schema.Array(ServerAuthSessionMethod),
147156 sessionCookieName: TrimmedNonEmptyString,
157 /** Older servers omit this and authorize self-updates with orchestration:operate. */
158 serverUpdateScope: Schema.optionalKey(Schema.Literal(AuthEnvironmentMaintainScope)),
148159});
149160export 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";
6262export * from "./rpc.ts";
6363export * from "./worktreeSetup.ts";
6464export * from "./secretRequest.ts";
65export * from "./clientRpcPermissions.ts";
packages/contracts/src/settings.ts+25
......@@ -1,4 +1,9 @@
11import { SshDeviceHostConfigs } from "./device.ts";
2import {
3 AuthSettingsWriteScope,
4 AuthProvidersManageScope,
5 type AuthEnvironmentScope,
6} from "./auth.ts";
27import * as Effect from "effect/Effect";
38import * as Duration from "effect/Duration";
49import * as Schema from "effect/Schema";
......@@ -1790,6 +1795,26 @@ export const ServerSettingsPatch = Schema.Struct({
17901795});
17911796export 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
17931818export const ClientSettingsPatch = Schema.Struct({
17941819 notificationMode: Schema.optionalKey(NotificationMode),
17951820 inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
vite.config.ts+41
......@@ -169,12 +169,53 @@ export default defineConfig({
169169 "t3code/no-native-title-tooltip": "error",
170170 "t3code/no-raw-mcp-registration": "error",
171171 "t3code/no-test-in-loop": "error",
172 "t3code/no-rpc-permission-bypass": ["error", { allowRawClientAccess: true }],
172173 "t3code/no-unscoped-has": "error",
173174 "t3code/namespace-node-imports": "error",
174175 "t3code/prefer-catch-tags": "error",
175176 "t3code/require-suppression-reason": "error",
176177 },
177178 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 },
178219 {
179220 // The one place that reads the host platform to seed the injected references.
180221 files: ["packages/shared/src/hostProcess.ts"],