authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 20:30:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:30:12-07:00
log57b2418d4fbab56290ff4cb861dcd0a7d0f5ef63
tree72b2ad9aeecb65ff4b954541137d794be6c55788
parent7175edcff4f250b583ebed8877e27e36dc48411e
signature Signed by PGP key B5690EEEBB952194

feat(auth): separate diagnostics and usage permissions (#9790)

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

15 files changed, 293 insertions(+), 33 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+26-6
...@@ -125,6 +125,9 @@ export function UsageRouteScreen() {...@@ -125,6 +125,9 @@ export function UsageRouteScreen() {
125 ),125 ),
126 ),126 ),
127 ];127 ];
128 const canReadDiagnostics = selectedEnvironments.some(
129 (environment) => environment.canReadDiagnostics,
130 );
128131
129 const days = useMemo(132 const days = useMemo(
130 () => enumerateDays(window.sinceDay, window.untilDay),133 () => enumerateDays(window.sinceDay, window.untilDay),
...@@ -272,10 +275,12 @@ export function UsageRouteScreen() {...@@ -272,10 +275,12 @@ export function UsageRouteScreen() {
272 contentContainerClassName="gap-6 px-5 pt-4"275 contentContainerClassName="gap-6 px-5 pt-4"
273 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}276 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
274 refreshControl={277 refreshControl={
275 <RefreshControl278 showingLimits || canReadDiagnostics ? (
276 refreshing={showingLimits ? limits.refreshing : refreshingUsage}279 <RefreshControl
277 onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}280 refreshing={showingLimits ? limits.refreshing : refreshingUsage}
278 />281 onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
282 />
283 ) : undefined
279 }284 }
280 >285 >
281 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />286 <SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
...@@ -335,6 +340,20 @@ export function UsageRouteScreen() {...@@ -335,6 +340,20 @@ export function UsageRouteScreen() {
335 ? "Connect an environment to see usage."340 ? "Connect an environment to see usage."
336 : "Select an environment to see usage."}341 : "Select an environment to see usage."}
337 </Text>342 </Text>
343 ) : !canReadDiagnostics ? (
344 // Each environment explains itself: a denied grant and a failed
345 // access check are different problems.
346 <View className="gap-2 py-16">
347 {selectedEnvironments.map((environment) => (
348 <Text
349 key={environment.environmentId}
350 className="text-center text-base text-foreground-muted"
351 >
352 {selectedEnvironments.length > 1 ? `${environment.label}: ` : null}
353 {environment.error}
354 </Text>
355 ))}
356 </View>
338 ) : (357 ) : (
339 <>358 <>
340 {sourceMessages.map((message) => (359 {sourceMessages.map((message) => (
...@@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {...@@ -857,10 +876,11 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
857 : "clientBehind",876 : "clientBehind",
858 });877 });
859 }878 }
879 // The reason matters: a denied grant and a failed scan need different fixes.
880 if (environment.error)
881 return environment.summary ? `${environment.error} Showing saved totals.` : environment.error;
860 if (!environment.isConnected)882 if (!environment.isConnected)
861 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";883 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
862 if (environment.error)
863 return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable";
864 if (isUsageLoading(environment))884 if (isUsageLoading(environment))
865 return environment.summary ? "Updating usage…" : "Loading usage…";885 return environment.summary ? "Updating usage…" : "Loading usage…";
866 return "Usage up to date";886 return "Usage up to date";
apps/mobile/src/state/usage.ts+31-1
...@@ -11,12 +11,14 @@...@@ -11,12 +11,14 @@
11 */11 */
12import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
13import {13import {
14 AuthDiagnosticsReadScope,
14 USAGE_CONTRACT_VERSION,15 USAGE_CONTRACT_VERSION,
15 type EnvironmentId,16 type EnvironmentId,
16 type UsageSummary,17 type UsageSummary,
17 type UsageSummaryInput,18 type UsageSummaryInput,
18} from "@t3tools/contracts";19} from "@t3tools/contracts";
19import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";20import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
21import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";22import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
21import * as Option from "effect/Option";23import * as Option from "effect/Option";
22import { AsyncResult, Atom } from "effect/reactivity";24import { AsyncResult, Atom } from "effect/reactivity";
...@@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react";...@@ -25,12 +27,14 @@ import { useCallback, useMemo } from "react";
25import { appAtomRegistry } from "./atom-registry";27import { appAtomRegistry } from "./atom-registry";
26import { environmentPresentations } from "./presentation";28import { environmentPresentations } from "./presentation";
27import { serverEnvironment } from "./server";29import { serverEnvironment } from "./server";
30import { environmentSession, readEnvironmentScope } from "./session";
2831
29export interface EnvironmentUsageStatus {32export interface EnvironmentUsageStatus {
30 readonly environmentId: EnvironmentId;33 readonly environmentId: EnvironmentId;
31 readonly label: string;34 readonly label: string;
32 readonly isPending: boolean;35 readonly isPending: boolean;
33 readonly isConnected: boolean;36 readonly isConnected: boolean;
37 readonly canReadDiagnostics: boolean;
34 readonly error: string | null;38 readonly error: string | null;
35 readonly summary: UsageSummary | null;39 readonly summary: UsageSummary | null;
36 readonly needsCursorKeychainAccess: boolean;40 readonly needsCursorKeychainAccess: boolean;
...@@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -50,6 +54,23 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
5054
51 const statuses: EnvironmentUsageStatus[] = [];55 const statuses: EnvironmentUsageStatus[] = [];
52 for (const [environmentId, presentation] of presentations) {56 for (const [environmentId, presentation] of presentations) {
57 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
58 const access = resolveUsageAccess({
59 connectionPhase: presentation.connection.phase,
60 session: Option.getOrNull(AsyncResult.value(sessionResult)),
61 hasSessionError: sessionResult._tag === "Failure",
62 });
63 if (!access.canReadDiagnostics) {
64 statuses.push({
65 environmentId,
66 label: presentation.entry.target.label,
67 isConnected: presentation.connection.phase === "connected",
68 ...access,
69 summary: null,
70 needsCursorKeychainAccess: false,
71 });
72 continue;
73 }
53 const result = get(serverEnvironment.usageSummary({ environmentId, input }));74 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
54 const summary = Option.getOrNull(AsyncResult.value(result));75 const summary = Option.getOrNull(AsyncResult.value(result));
55 statuses.push({76 statuses.push({
...@@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -57,6 +78,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
57 label: presentation.entry.target.label,78 label: presentation.entry.target.label,
58 isPending: result.waiting,79 isPending: result.waiting,
59 isConnected: presentation.connection.phase === "connected",80 isConnected: presentation.connection.phase === "connected",
81 canReadDiagnostics: true,
60 error: result._tag === "Failure" ? "This environment could not report usage." : null,82 error: result._tag === "Failure" ? "This environment could not report usage." : null,
61 summary,83 summary,
62 needsCursorKeychainAccess: needsCursorKeychainAccess(84 needsCursorKeychainAccess: needsCursorKeychainAccess(
...@@ -123,7 +145,15 @@ export function useUsage(...@@ -123,7 +145,15 @@ export function useUsage(
123 registry: appAtomRegistry,145 registry: appAtomRegistry,
124 server: serverEnvironment,146 server: serverEnvironment,
125 presentations: environmentPresentations,147 presentations: environmentPresentations,
126 environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),148 // Only environments this connection may read; the others report a
149 // permission error instead of a stale or failed rescan.
150 environmentIds: selectedEnvironments
151 .filter(
152 (environment) =>
153 environment.canReadDiagnostics &&
154 readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope),
155 )
156 .map(({ environmentId }) => environmentId),
127 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),157 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
128 }),158 }),
129 [selectedEnvironments, windowKey],159 [selectedEnvironments, windowKey],
apps/server/src/auth/RpcAuthorization.test.ts+14-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 AuthEnvironmentMaintainScope,2 AuthEnvironmentMaintainScope,
3 AuthDiagnosticsReadScope,
3 AuthFilesystemReadScope,4 AuthFilesystemReadScope,
4 AuthProvidersManageScope,5 AuthProvidersManageScope,
5 AuthSettingsWriteScope,6 AuthSettingsWriteScope,
...@@ -193,7 +194,17 @@ describe("RPC scope middleware", () => {...@@ -193,7 +194,17 @@ describe("RPC scope middleware", () => {
193 ),194 ),
194 );195 );
195196
196 it.effect("checks each RPC's declared scope before its handler runs", () =>197 it.effect.each([
198 { scopes: [AuthOrchestrationReadScope], missing: AuthEnvironmentMaintainScope },
199 {
200 scopes: [AuthOrchestrationReadScope, AuthEnvironmentMaintainScope],
201 missing: AuthDiagnosticsReadScope,
202 },
203 {
204 scopes: [AuthOrchestrationReadScope, AuthDiagnosticsReadScope],
205 missing: AuthEnvironmentMaintainScope,
206 },
207 ])("rejects telemetry retry without $missing before its handler runs", ({ scopes, missing }) =>
197 Effect.gen(function* () {208 Effect.gen(function* () {
198 const handled: Array<string> = [];209 const handled: Array<string> = [];
199 const client = yield* RpcTest.makeClient(group).pipe(210 const client = yield* RpcTest.makeClient(group).pipe(
...@@ -203,7 +214,7 @@ describe("RPC scope middleware", () => {...@@ -203,7 +214,7 @@ describe("RPC scope middleware", () => {
203 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>214 group.toLayerHandler(WS_METHODS.serverRetryResourceTelemetry, () =>
204 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),215 Effect.sync(() => handled.push("retry")).pipe(Effect.andThen(Effect.never)),
205 ),216 ),
206 RpcAuthorization.layer([AuthOrchestrationReadScope]),217 RpcAuthorization.layer(scopes),
207 ),218 ),
208 ),219 ),
209 );220 );
...@@ -213,7 +224,7 @@ describe("RPC scope middleware", () => {...@@ -213,7 +224,7 @@ describe("RPC scope middleware", () => {
213 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),224 yield* client[WS_METHODS.serverRetryResourceTelemetry]({}).pipe(Effect.flip),
214 ).toMatchObject({225 ).toMatchObject({
215 _tag: "EnvironmentAuthorizationError",226 _tag: "EnvironmentAuthorizationError",
216 requiredScope: AuthEnvironmentMaintainScope,227 requiredScope: missing,
217 });228 });
218 expect(handled).toEqual([]);229 expect(handled).toEqual([]);
219 }).pipe(Effect.scoped),230 }).pipe(Effect.scoped),
apps/server/src/auth/RpcAuthorization.ts+14-8
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 AuthEnvironmentMaintainScope,12 AuthEnvironmentMaintainScope,
13 AuthFilesystemReadScope,13 AuthFilesystemReadScope,
14 AuthFilesystemWriteScope,14 AuthFilesystemWriteScope,
15 AuthDiagnosticsReadScope,
15 AuthOrchestrationOperateScope,16 AuthOrchestrationOperateScope,
16 AuthOrchestrationReadScope,17 AuthOrchestrationReadScope,
17 AuthPreviewOperateScope,18 AuthPreviewOperateScope,
...@@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = {...@@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = {
90 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,91 [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope,
91 [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,92 [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope,
92 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,93 [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
93 [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,94 [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope,
94 [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,95 [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope,
96 // Load-balancing new threads reads host load; that is part of operating
97 // threads, not of inspecting diagnostics.
95 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,98 [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope,
96 [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,99 [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope,
97 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,100 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
98 [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope,101 [WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
99 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,102 [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
100 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,103 [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
101 [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,104 [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
102 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,105 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
103 [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,106 [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope,
...@@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = {
153 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,156 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
154 [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,157 [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
155 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,158 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
156 [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope,159 [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope,
157 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,160 [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope,
158 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,161 [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope,
159 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,162 [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope,
...@@ -230,6 +233,9 @@ const requiredScopesForRpcCall = (...@@ -230,6 +233,9 @@ const requiredScopesForRpcCall = (
230 method: string,233 method: string,
231 payload: unknown,234 payload: unknown,
232): ReadonlyArray<AuthEnvironmentScope> => {235): ReadonlyArray<AuthEnvironmentScope> => {
236 if (method === WS_METHODS.serverRetryResourceTelemetry) {
237 return [AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope];
238 }
233 if (method === WS_METHODS.assetsCreateUrl) {239 if (method === WS_METHODS.assetsCreateUrl) {
234 const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload);240 const { resource } = Schema.decodeUnknownSync(AssetCreateUrlInput)(payload);
235 return [241 return [
apps/web/src/components/settings/ConnectionsSettings.tsx+11-3
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 AuthSettingsWriteScope,26 AuthSettingsWriteScope,
27 AuthProvidersManageScope,27 AuthProvidersManageScope,
28 AuthEnvironmentMaintainScope,28 AuthEnvironmentMaintainScope,
29 AuthDiagnosticsReadScope,
29 AuthOrchestrationOperateScope,30 AuthOrchestrationOperateScope,
30 AuthOrchestrationReadScope,31 AuthOrchestrationReadScope,
31 AuthPreviewOperateScope,32 AuthPreviewOperateScope,
...@@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{...@@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{
263 title: "Control previews",264 title: "Control previews",
264 description: "Open browser previews and host browser automation.",265 description: "Open browser previews and host browser automation.",
265 },266 },
267 {
268 scope: AuthDiagnosticsReadScope,
269 title: "View diagnostics and usage",
270 description: "Read process diagnostics, resource history, and usage totals.",
271 },
266 {272 {
267 scope: AuthTerminalOperateScope,273 scope: AuthTerminalOperateScope,
268 title: "Use terminals",274 title: "Use terminals",
...@@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio...@@ -1230,9 +1236,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio
1230 disabled={isCreatingPairingLink}1236 disabled={isCreatingPairingLink}
1231 onClick={() =>1237 onClick={() =>
1232 setPairingScopes(1238 setPairingScopes(
1233 [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) =>1239 [
1234 delegatableScopes.includes(scope),1240 AuthOrchestrationReadScope,
1235 ),1241 AuthFilesystemReadScope,
1242 AuthDiagnosticsReadScope,
1243 ].filter((scope) => delegatableScopes.includes(scope)),
1236 )1244 )
1237 }1245 }
1238 >1246 >
apps/web/src/components/settings/DiagnosticsSettings.tsx+28-4
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { ProcessSignalActions } from "./ProcessSignalActions";1import { ProcessSignalActions } from "./ProcessSignalActions";
2import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
3import { environmentSession } from "../../state/session";
2import { AuthOrchestrationOperateScope } from "@t3tools/contracts";4import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";5import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
4import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";6import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
...@@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences";...@@ -24,7 +26,6 @@ import { useOpenInPreferredEditor } from "../../editorPreferences";
24import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";26import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
25import { useEnvironmentQuery } from "../../state/query";27import { useEnvironmentQuery } from "../../state/query";
26import { serverEnvironment } from "../../state/server";28import { serverEnvironment } from "../../state/server";
27import { shellEnvironment } from "../../state/shell";
28import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";29import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
29import { Button } from "../ui/button";30import { Button } from "../ui/button";
30import { MorphIcon } from "~/components/MorphIcon";31import { MorphIcon } from "~/components/MorphIcon";
...@@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() {...@@ -719,6 +720,15 @@ export function DiagnosticsSettingsPanel() {
719 const observability = environment?.serverConfig?.observability;720 const observability = environment?.serverConfig?.observability;
720 const availableEditors = environment?.serverConfig?.availableEditors;721 const availableEditors = environment?.serverConfig?.availableEditors;
721 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);722 const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
723 const session = useEnvironmentQuery(
724 environmentId === null ? null : environmentSession.sessionStateAtom(environmentId),
725 );
726 const diagnosticsAccess = resolveUsageAccess({
727 connectionPhase: environment?.connection.phase ?? "available",
728 session: session.data,
729 hasSessionError: session.error !== null,
730 });
731 const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics;
722 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {732 const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
723 reportFailure: false,733 reportFailure: false,
724 });734 });
...@@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() {...@@ -728,7 +738,7 @@ export function DiagnosticsSettingsPanel() {
728 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??738 RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ??
729 RESOURCE_HISTORY_WINDOWS[1];739 RESOURCE_HISTORY_WINDOWS[1];
730 const { data, error, isPending, refresh } = useEnvironmentQuery(740 const { data, error, isPending, refresh } = useEnvironmentQuery(
731 environmentId === null741 environmentId === null || !canReadDiagnostics
732 ? null742 ? null
733 : serverEnvironment.traceDiagnostics({ environmentId, input: {} }),743 : serverEnvironment.traceDiagnostics({ environmentId, input: {} }),
734 );744 );
...@@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() {...@@ -738,7 +748,7 @@ export function DiagnosticsSettingsPanel() {
738 isPending: isProcessPending,748 isPending: isProcessPending,
739 refresh: refreshProcesses,749 refresh: refreshProcesses,
740 } = useEnvironmentQuery(750 } = useEnvironmentQuery(
741 environmentId === null751 environmentId === null || !canReadDiagnostics
742 ? null752 ? null
743 : serverEnvironment.processDiagnostics({ environmentId, input: {} }),753 : serverEnvironment.processDiagnostics({ environmentId, input: {} }),
744 );754 );
...@@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() {...@@ -748,7 +758,7 @@ export function DiagnosticsSettingsPanel() {
748 isPending: isResourcePending,758 isPending: isResourcePending,
749 refresh: refreshResources,759 refresh: refreshResources,
750 } = useEnvironmentQuery(760 } = useEnvironmentQuery(
751 environmentId === null761 environmentId === null || !canReadDiagnostics
752 ? null762 ? null
753 : serverEnvironment.processResourceHistory({763 : serverEnvironment.processResourceHistory({
754 environmentId,764 environmentId,
...@@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() {...@@ -907,6 +917,20 @@ export function DiagnosticsSettingsPanel() {
907 ? Option.getOrElse(data.partialFailure, () => false)917 ? Option.getOrElse(data.partialFailure, () => false)
908 : false;918 : false;
909919
920 if (!canReadDiagnostics) {
921 return (
922 <SettingsPageContainer>
923 <p className="text-sm text-muted-foreground">
924 {environmentId === null
925 ? "Connect an environment to see diagnostics."
926 : diagnosticsAccess.isPending
927 ? "Checking diagnostics access…"
928 : diagnosticsAccess.error}
929 </p>
930 </SettingsPageContainer>
931 );
932 }
933
910 return (934 return (
911 <SettingsPageContainer width="expanded" className="gap-10">935 <SettingsPageContainer width="expanded" className="gap-10">
912 <ResourceTelemetryDiagnostics environmentId={environmentId} />936 <ResourceTelemetryDiagnostics environmentId={environmentId} />
apps/web/src/components/usage/UsagePage.test.tsx+1
...@@ -52,6 +52,7 @@ const environments = [...@@ -52,6 +52,7 @@ const environments = [
52 environmentId: EnvironmentId.make("test-environment"),52 environmentId: EnvironmentId.make("test-environment"),
53 label: "Test environment",53 label: "Test environment",
54 isPending: false,54 isPending: false,
55 canReadDiagnostics: true,
55 error: null,56 error: null,
56 summary: {57 summary: {
57 contractVersion: USAGE_CONTRACT_VERSION,58 contractVersion: USAGE_CONTRACT_VERSION,
apps/web/src/components/usage/UsagePage.tsx+18-3
...@@ -167,6 +167,10 @@ export function UsagePage() {...@@ -167,6 +167,10 @@ export function UsagePage() {
167 reportFailure: false,167 reportFailure: false,
168 });168 });
169169
170 const canReadDiagnostics = selectedEnvironments.some(
171 (environment) => environment.canReadDiagnostics,
172 );
173
170 const days = useMemo(174 const days = useMemo(
171 () => enumerateDays(window.sinceDay, window.untilDay),175 () => enumerateDays(window.sinceDay, window.untilDay),
172 [window.sinceDay, window.untilDay],176 [window.sinceDay, window.untilDay],
...@@ -387,7 +391,7 @@ export function UsagePage() {...@@ -387,7 +391,7 @@ export function UsagePage() {
387 onClick={refreshWindow}391 onClick={refreshWindow}
388 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}392 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
389 aria-busy={isRefreshing}393 aria-busy={isRefreshing}
390 disabled={isRefreshing}394 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
391 size="icon-sm"395 size="icon-sm"
392 variant="ghost"396 variant="ghost"
393 >397 >
...@@ -450,7 +454,7 @@ export function UsagePage() {...@@ -450,7 +454,7 @@ export function UsagePage() {
450 onClick={refreshWindow}454 onClick={refreshWindow}
451 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}455 aria-label={showingLimits ? "Refresh limits" : "Refresh usage"}
452 aria-busy={isRefreshing}456 aria-busy={isRefreshing}
453 disabled={isRefreshing}457 disabled={isRefreshing || (!showingLimits && !canReadDiagnostics)}
454 size="icon-sm"458 size="icon-sm"
455 variant="ghost"459 variant="ghost"
456 >460 >
...@@ -493,6 +497,15 @@ export function UsagePage() {...@@ -493,6 +497,15 @@ export function UsagePage() {
493 />497 />
494 ) : isPending ? (498 ) : isPending ? (
495 <UsageSkeleton />499 <UsageSkeleton />
500 ) : !canReadDiagnostics ? (
501 <div className="space-y-2 py-12 text-center text-sm text-muted-foreground">
502 {selectedEnvironments.map((environment) => (
503 <p key={environment.environmentId}>
504 {selectedEnvironments.length > 1 ? `${environment.label}: ` : null}
505 {environment.error}
506 </p>
507 ))}
508 </div>
496 ) : (509 ) : (
497 <>510 <>
498 {sourceMessages.map((message) => (511 {sourceMessages.map((message) => (
...@@ -1060,7 +1073,9 @@ function UsageCoverageNotice({...@@ -1060,7 +1073,9 @@ function UsageCoverageNotice({
1060 return (1073 return (
1061 <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">1074 <div className="flex flex-col gap-1 border-t border-border px-2 py-2 text-xs text-muted-foreground">
1062 {failed.map((environment) => (1075 {failed.map((environment) => (
1063 <span key={environment.label}>{environment.label} could not report usage.</span>1076 <span key={environment.label}>
1077 {environment.label}: {environment.error}
1078 </span>
1064 ))}1079 ))}
1065 {incompatible.map(({ environment, mismatch }) => (1080 {incompatible.map(({ environment, mismatch }) => (
1066 <span key={environment.environmentId}>1081 <span key={environment.environmentId}>
apps/web/src/lib/resourceTelemetryState.ts+10-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { AuthDiagnosticsReadScope } from "@t3tools/contracts";
1import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";2import { AuthEnvironmentMaintainScope } from "@t3tools/contracts";
2import type {3import type {
3 EnvironmentId,4 EnvironmentId,
...@@ -10,7 +11,7 @@ import { useCallback } from "react";...@@ -10,7 +11,7 @@ import { useCallback } from "react";
10import { usePrimaryEnvironment } from "../state/environments";11import { usePrimaryEnvironment } from "../state/environments";
11import { useEnvironmentQuery } from "../state/query";12import { useEnvironmentQuery } from "../state/query";
12import { serverEnvironment } from "../state/server";13import { serverEnvironment } from "../state/server";
13import { readEnvironmentScope } from "../state/session";14import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
14import { useAtomCommand } from "../state/use-atom-command";15import { useAtomCommand } from "../state/use-atom-command";
1516
16export interface ResourceTelemetryState {17export interface ResourceTelemetryState {
...@@ -29,8 +30,9 @@ export function useResourceTelemetry(...@@ -29,8 +30,9 @@ export function useResourceTelemetry(
29 targetEnvironmentId === undefined30 targetEnvironmentId === undefined
30 ? (primaryEnvironment?.environmentId ?? null)31 ? (primaryEnvironment?.environmentId ?? null)
31 : targetEnvironmentId;32 : targetEnvironmentId;
33 const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope);
32 const query = useEnvironmentQuery(34 const query = useEnvironmentQuery(
33 environmentId === null35 environmentId === null || !canReadDiagnostics
34 ? null36 ? null
35 : serverEnvironment.resourceTelemetry({ environmentId, input: {} }),37 : serverEnvironment.resourceTelemetry({ environmentId, input: {} }),
36 );38 );
...@@ -41,7 +43,10 @@ export function useResourceTelemetry(...@@ -41,7 +43,10 @@ export function useResourceTelemetry(
41 if (environmentId === null) {43 if (environmentId === null) {
42 throw new Error("No environment is selected.");44 throw new Error("No environment is selected.");
43 }45 }
44 if (!readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope)) {46 if (
47 !readEnvironmentScope(environmentId, AuthEnvironmentMaintainScope) ||
48 !readEnvironmentScope(environmentId, AuthDiagnosticsReadScope)
49 ) {
45 throw new Error("This connection cannot restart the resource monitor.");50 throw new Error("This connection cannot restart the resource monitor.");
46 }51 }
47 const result = await retryCommand({ environmentId, input: {} });52 const result = await retryCommand({ environmentId, input: {} });
...@@ -63,8 +68,9 @@ export function useResourceTelemetryHistory(...@@ -63,8 +68,9 @@ export function useResourceTelemetryHistory(
63 targetEnvironmentId === undefined68 targetEnvironmentId === undefined
64 ? (primaryEnvironment?.environmentId ?? null)69 ? (primaryEnvironment?.environmentId ?? null)
65 : targetEnvironmentId;70 : targetEnvironmentId;
71 const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope);
66 return useEnvironmentQuery(72 return useEnvironmentQuery(
67 environmentId === null73 environmentId === null || !canReadDiagnostics
68 ? null74 ? null
69 : serverEnvironment.resourceTelemetryHistory({ environmentId, input }),75 : serverEnvironment.resourceTelemetryHistory({ environmentId, input }),
70 );76 );
apps/web/src/state/usage.test.tsx+1
...@@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU...@@ -22,6 +22,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
22 environmentId: EnvironmentId.make(id),22 environmentId: EnvironmentId.make(id),
23 label: id,23 label: id,
24 isPending: cost === null,24 isPending: cost === null,
25 canReadDiagnostics: true,
25 error: null,26 error: null,
26 needsCursorKeychainAccess: false,27 needsCursorKeychainAccess: false,
27 summary:28 summary:
apps/web/src/state/usage.ts+30-1
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 */8 */
9import { useAtomValue } from "@effect/atom-react";9import { useAtomValue } from "@effect/atom-react";
10import {10import {
11 AuthDiagnosticsReadScope,
11 USAGE_CONTRACT_VERSION,12 USAGE_CONTRACT_VERSION,
12 type EnvironmentId,13 type EnvironmentId,
13 type UsageBucket,14 type UsageBucket,
...@@ -15,6 +16,7 @@ import {...@@ -15,6 +16,7 @@ import {
15 type UsageSummaryInput,16 type UsageSummaryInput,
16} from "@t3tools/contracts";17} from "@t3tools/contracts";
17import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";18import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
19import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
18import * as Option from "effect/Option";20import * as Option from "effect/Option";
19import { AsyncResult, Atom } from "effect/reactivity";21import { AsyncResult, Atom } from "effect/reactivity";
20import { useCallback, useMemo } from "react";22import { useCallback, useMemo } from "react";
...@@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh...@@ -23,11 +25,13 @@ import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/sh
23import { appAtomRegistry } from "../rpc/atomRegistry";25import { appAtomRegistry } from "../rpc/atomRegistry";
24import { environmentPresentations } from "./presentation";26import { environmentPresentations } from "./presentation";
25import { serverEnvironment } from "./server";27import { serverEnvironment } from "./server";
28import { environmentSession, readEnvironmentScope } from "./session";
2629
27export interface EnvironmentUsageStatus {30export interface EnvironmentUsageStatus {
28 readonly environmentId: EnvironmentId;31 readonly environmentId: EnvironmentId;
29 readonly label: string;32 readonly label: string;
30 readonly isPending: boolean;33 readonly isPending: boolean;
34 readonly canReadDiagnostics: boolean;
31 readonly error: string | null;35 readonly error: string | null;
32 readonly summary: UsageSummary | null;36 readonly summary: UsageSummary | null;
33 readonly needsCursorKeychainAccess: boolean;37 readonly needsCursorKeychainAccess: boolean;
...@@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -47,12 +51,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
4751
48 const statuses: EnvironmentUsageStatus[] = [];52 const statuses: EnvironmentUsageStatus[] = [];
49 for (const [environmentId, presentation] of presentations) {53 for (const [environmentId, presentation] of presentations) {
54 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
55 const access = resolveUsageAccess({
56 connectionPhase: presentation.connection.phase,
57 session: Option.getOrNull(AsyncResult.value(sessionResult)),
58 hasSessionError: sessionResult._tag === "Failure",
59 });
60 if (!access.canReadDiagnostics) {
61 statuses.push({
62 environmentId,
63 label: presentation.entry.target.label,
64 ...access,
65 summary: null,
66 needsCursorKeychainAccess: false,
67 });
68 continue;
69 }
50 const result = get(serverEnvironment.usageSummary({ environmentId, input }));70 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
51 const summary = Option.getOrNull(AsyncResult.value(result));71 const summary = Option.getOrNull(AsyncResult.value(result));
52 statuses.push({72 statuses.push({
53 environmentId,73 environmentId,
54 label: presentation.entry.target.label,74 label: presentation.entry.target.label,
55 isPending: result.waiting,75 isPending: result.waiting,
76 canReadDiagnostics: true,
56 error: result._tag === "Failure" ? "This environment could not report usage." : null,77 error: result._tag === "Failure" ? "This environment could not report usage." : null,
57 summary,78 summary,
58 needsCursorKeychainAccess: needsCursorKeychainAccess(79 needsCursorKeychainAccess: needsCursorKeychainAccess(
...@@ -148,7 +169,15 @@ export function useUsage(...@@ -148,7 +169,15 @@ export function useUsage(
148 registry: appAtomRegistry,169 registry: appAtomRegistry,
149 server: serverEnvironment,170 server: serverEnvironment,
150 presentations: environmentPresentations,171 presentations: environmentPresentations,
151 environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId),172 // Only environments this connection may read; the others report a
173 // permission error instead of a stale or failed rescan.
174 environmentIds: selectedEnvironments
175 .filter(
176 (environment) =>
177 environment.canReadDiagnostics &&
178 readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope),
179 )
180 .map(({ environmentId }) => environmentId),
152 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),181 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
153 }),182 }),
154 [selectedEnvironments, windowKey],183 [selectedEnvironments, windowKey],
packages/client-runtime/package.json+4
...@@ -243,6 +243,10 @@...@@ -243,6 +243,10 @@
243 "types": "./src/state/session.ts",243 "types": "./src/state/session.ts",
244 "default": "./src/state/session.ts"244 "default": "./src/state/session.ts"
245 },245 },
246 "./state/usage-access": {
247 "types": "./src/state/usageAccess.ts",
248 "default": "./src/state/usageAccess.ts"
249 },
246 "./state/shared-settings": {250 "./state/shared-settings": {
247 "types": "./src/state/sharedSettings.ts",251 "types": "./src/state/sharedSettings.ts",
248 "default": "./src/state/sharedSettings.ts"252 "default": "./src/state/sharedSettings.ts"
packages/client-runtime/src/state/usageAccess.test.ts created+62
...@@ -0,0 +1,62 @@
1import { AuthDiagnosticsReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { resolveUsageAccess } from "./usageAccess.ts";
5
6describe("resolveUsageAccess", () => {
7 it.each(["available", "offline", "error"] as const)(
8 "reports %s connections without a session instead of keeping totals pending",
9 (connectionPhase) => {
10 expect(
11 resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }),
12 ).toEqual({
13 canReadDiagnostics: false,
14 isPending: false,
15 error: "This environment is not connected.",
16 });
17 },
18 );
19
20 it.each(["connecting", "reconnecting", "connected"] as const)(
21 "waits for a session check while %s",
22 (connectionPhase) => {
23 expect(
24 resolveUsageAccess({ connectionPhase, session: null, hasSessionError: false }),
25 ).toEqual({ canReadDiagnostics: false, isPending: true, error: null });
26 },
27 );
28
29 it("preserves a known grant so disconnected usage can retain its cached summary", () => {
30 expect(
31 resolveUsageAccess({
32 connectionPhase: "offline",
33 session: { authenticated: true, scopes: [AuthDiagnosticsReadScope] },
34 hasSessionError: false,
35 }),
36 ).toEqual({ canReadDiagnostics: true, isPending: false, error: null });
37 });
38
39 it("distinguishes a failed check from a resolved grant without diagnostics access", () => {
40 const denied = resolveUsageAccess({
41 connectionPhase: "connected",
42 session: { authenticated: true, scopes: [AuthOrchestrationReadScope] },
43 hasSessionError: false,
44 });
45 const failed = resolveUsageAccess({
46 connectionPhase: "connected",
47 session: null,
48 hasSessionError: true,
49 });
50
51 expect(denied).toEqual({
52 canReadDiagnostics: false,
53 isPending: false,
54 error: "This connection does not have access to diagnostics and usage.",
55 });
56 expect(failed).toEqual({
57 canReadDiagnostics: false,
58 isPending: false,
59 error: "Could not check this connection's access to diagnostics and usage.",
60 });
61 });
62});
packages/client-runtime/src/state/usageAccess.ts created+40
...@@ -0,0 +1,40 @@
1import { AuthDiagnosticsReadScope, type AuthSessionState } from "@t3tools/contracts";
2
3import type { EnvironmentConnectionPhase } from "../connection/presentation.ts";
4
5export function resolveUsageAccess(input: {
6 readonly connectionPhase: EnvironmentConnectionPhase;
7 readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null;
8 readonly hasSessionError: boolean;
9}) {
10 if (input.hasSessionError) {
11 return {
12 canReadDiagnostics: false,
13 isPending: false,
14 error: "Could not check this connection's access to diagnostics and usage.",
15 };
16 }
17 if (input.session === null) {
18 // An unprepared offline connection cannot finish its session check. Keep
19 // it out of pending totals until the connection starts another attempt.
20 const isPending =
21 input.connectionPhase === "connected" ||
22 input.connectionPhase === "connecting" ||
23 input.connectionPhase === "reconnecting";
24 return {
25 canReadDiagnostics: false,
26 isPending,
27 error: isPending ? null : "This environment is not connected.",
28 };
29 }
30 const canReadDiagnostics =
31 input.session.authenticated &&
32 input.session.scopes?.includes(AuthDiagnosticsReadScope) === true;
33 return {
34 canReadDiagnostics,
35 isPending: false,
36 error: canReadDiagnostics
37 ? null
38 : "This connection does not have access to diagnostics and usage.",
39 };
40}
packages/contracts/src/auth.ts+3
...@@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const;...@@ -84,6 +84,7 @@ export const AuthSettingsWriteScope = "settings:write" as const;
84export const AuthProvidersManageScope = "providers:manage" as const;84export const AuthProvidersManageScope = "providers:manage" as const;
85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;85export const AuthEnvironmentMaintainScope = "environment:maintain" as const;
86export const AuthPreviewOperateScope = "preview:operate" as const;86export const AuthPreviewOperateScope = "preview:operate" as const;
87export const AuthDiagnosticsReadScope = "diagnostics:read" as const;
87export const AuthTerminalOperateScope = "terminal:operate" as const;88export const AuthTerminalOperateScope = "terminal:operate" as const;
88export const AuthSourceControlWriteScope = "source-control:write" as const;89export const AuthSourceControlWriteScope = "source-control:write" as const;
89export const AuthFilesystemReadScope = "filesystem:read" as const;90export const AuthFilesystemReadScope = "filesystem:read" as const;
...@@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([...@@ -101,6 +102,7 @@ export const AuthEnvironmentScope = Schema.Literals([
101 AuthProvidersManageScope,102 AuthProvidersManageScope,
102 AuthEnvironmentMaintainScope,103 AuthEnvironmentMaintainScope,
103 AuthPreviewOperateScope,104 AuthPreviewOperateScope,
105 AuthDiagnosticsReadScope,
104 AuthTerminalOperateScope,106 AuthTerminalOperateScope,
105 AuthFilesystemReadScope,107 AuthFilesystemReadScope,
106 AuthFilesystemWriteScope,108 AuthFilesystemWriteScope,
...@@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [...@@ -129,6 +131,7 @@ export const AuthStandardClientScopes = [
129 AuthProvidersManageScope,131 AuthProvidersManageScope,
130 AuthEnvironmentMaintainScope,132 AuthEnvironmentMaintainScope,
131 AuthPreviewOperateScope,133 AuthPreviewOperateScope,
134 AuthDiagnosticsReadScope,
132 AuthTerminalOperateScope,135 AuthTerminalOperateScope,
133 AuthSourceControlWriteScope,136 AuthSourceControlWriteScope,
134 AuthFilesystemReadScope,137 AuthFilesystemReadScope,