From 8640f3c7abda6f9cc8e722a3f244a63bc54065f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 13:41:05 -0700 Subject: [PATCH 01/12] feat(auth): authorize diagnostics and usage reads separately --- .../src/features/usage/UsageRouteScreen.tsx | 17 +++++++-- apps/mobile/src/state/usage.ts | 37 ++++++++++++++++++- apps/server/src/auth/RpcAuthorization.ts | 17 +++++---- .../settings/ConnectionsSettings.tsx | 6 +++ .../settings/DiagnosticsSettings.tsx | 18 +++++++-- .../src/components/usage/UsagePage.test.tsx | 1 + apps/web/src/components/usage/UsagePage.tsx | 19 +++++++++- apps/web/src/lib/resourceTelemetryState.ts | 9 +++-- apps/web/src/state/usage.test.tsx | 1 + apps/web/src/state/usage.ts | 32 +++++++++++++++- packages/contracts/src/auth.ts | 3 ++ 11 files changed, 140 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 5a8a4b1a15d4..da8e677ad9c6 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -125,6 +125,9 @@ export function UsageRouteScreen() { ), ), ]; + const canReadDiagnostics = selectedEnvironments.some( + (environment) => environment.canReadDiagnostics, + ); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -272,10 +275,12 @@ export function UsageRouteScreen() { contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} refreshControl={ - void limits.refresh() : refreshWindow} - /> + showingLimits || canReadDiagnostics ? ( + void limits.refresh() : refreshWindow} + /> + ) : undefined } > @@ -335,6 +340,10 @@ export function UsageRouteScreen() { ? "Connect an environment to see usage." : "Select an environment to see usage."} + ) : !canReadDiagnostics ? ( + + This connection does not have access to diagnostics and usage. + ) : ( <> {sourceMessages.map((message) => ( diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index a2e7589aadb6..9045f3eedb69 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -11,6 +11,7 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + AuthDiagnosticsReadScope, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageSummary, @@ -25,12 +26,14 @@ import { useCallback, useMemo } from "react"; import { appAtomRegistry } from "./atom-registry"; import { environmentPresentations } from "./presentation"; import { serverEnvironment } from "./server"; +import { environmentSession, readEnvironmentScope } from "./session"; export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; readonly isConnected: boolean; + readonly canReadDiagnostics: boolean; readonly error: string | null; readonly summary: UsageSummary | null; readonly needsCursorKeychainAccess: boolean; @@ -50,6 +53,29 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const isCheckingAccess = + sessionResult.waiting || (session === null && sessionResult._tag !== "Failure"); + const canReadDiagnostics = + sessionResult._tag === "Success" && + !isCheckingAccess && + session?.authenticated === true && + (session.scopes?.includes(AuthDiagnosticsReadScope) ?? false); + if (!canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + isPending: isCheckingAccess, + isConnected: presentation.connection.phase === "connected", + canReadDiagnostics: false, + error: isCheckingAccess + ? null + : "This connection does not have access to diagnostics and usage.", + summary: null, + }); + continue; + } const result = get(serverEnvironment.usageSummary({ environmentId, input })); const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ @@ -57,6 +83,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", + canReadDiagnostics: true, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary, needsCursorKeychainAccess: needsCursorKeychainAccess( @@ -123,7 +150,15 @@ export function useUsage( registry: appAtomRegistry, server: serverEnvironment, presentations: environmentPresentations, - environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + // Only environments this connection may read; the others report a + // permission error instead of a stale or failed rescan. + environmentIds: selectedEnvironments + .filter( + (environment) => + environment.canReadDiagnostics && + readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope), + ) + .map(({ environmentId }) => environmentId), input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), }), [selectedEnvironments, windowKey], diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28815d53cfa4..218959ef9d8a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -12,6 +12,7 @@ import { AuthEnvironmentMaintainScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -90,14 +91,16 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDisableAcpRegistryProvider]: AuthProvidersManageScope, [WS_METHODS.serverLogoutAcpRegistry]: AuthProvidersManageScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope, + // Load-balancing new threads reads host load; that is part of operating + // threads, not of inspecting diagnostics. [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, - [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope, [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, @@ -153,7 +156,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, - [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e6a5d88942e6..0e84ad056fd5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -26,6 +26,7 @@ import { AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -263,6 +264,11 @@ const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ title: "Control previews", description: "Open browser previews and host browser automation.", }, + { + scope: AuthDiagnosticsReadScope, + title: "View diagnostics and usage", + description: "Read process diagnostics, resource history, and usage totals.", + }, { scope: AuthTerminalOperateScope, title: "Use terminals", diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 18abfa37e1aa..cfe8532ebad5 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,4 +1,5 @@ import { ProcessSignalActions } from "./ProcessSignalActions"; +import { AuthDiagnosticsReadScope } from "@t3tools/contracts"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { AuthEnvironmentMaintainScope } from "@t3tools/contracts"; @@ -719,6 +720,7 @@ export function DiagnosticsSettingsPanel() { const observability = environment?.serverConfig?.observability; const availableEditors = environment?.serverConfig?.availableEditors; const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const canReadDiagnostics = useEnvironmentScope(environmentId, AuthDiagnosticsReadScope); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -728,7 +730,7 @@ export function DiagnosticsSettingsPanel() { RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? RESOURCE_HISTORY_WINDOWS[1]; const { data, error, isPending, refresh } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.traceDiagnostics({ environmentId, input: {} }), ); @@ -738,7 +740,7 @@ export function DiagnosticsSettingsPanel() { isPending: isProcessPending, refresh: refreshProcesses, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processDiagnostics({ environmentId, input: {} }), ); @@ -748,7 +750,7 @@ export function DiagnosticsSettingsPanel() { isPending: isResourcePending, refresh: refreshResources, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processResourceHistory({ environmentId, @@ -907,6 +909,16 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (!canReadDiagnostics) { + return ( + +

+ This connection does not have access to diagnostics. +

+
+ ); + } + return ( diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 6d8caac6a36d..53093e7ef23f 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -52,6 +52,7 @@ const environments = [ environmentId: EnvironmentId.make("test-environment"), label: "Test environment", isPending: false, + canReadDiagnostics: true, error: null, summary: { contractVersion: USAGE_CONTRACT_VERSION, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 969ca5f4e907..bca4d371ff45 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -167,6 +167,10 @@ export function UsagePage() { reportFailure: false, }); + const canReadDiagnostics = selectedEnvironments.some( + (environment) => environment.canReadDiagnostics, + ); + const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], @@ -385,6 +389,7 @@ export function UsagePage() {