diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..e7c986cdbe28 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -66,6 +66,7 @@ export function UsageRouteScreen() { const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); const limits = useRefreshLimits(); + const canReadDiagnostics = environments.some((environment) => environment.canReadDiagnostics); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -134,10 +135,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 } > @@ -164,11 +167,6 @@ export function UsageRouteScreen() { className="w-36" /> - {isPending ? ( Scanning provider transcripts… @@ -177,8 +175,25 @@ export function UsageRouteScreen() { Connect an environment to see usage. + ) : !canReadDiagnostics ? ( + + {environments.map((environment) => ( + + {environments.length > 1 ? `${environment.label}: ` : null} + {environment.error} + + ))} + ) : ( <> + ( - {environment.label} could not report usage. + {environment.label}: {environment.error} ))} {stale.map((environment) => ( diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..93ee2c7860a8 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -11,12 +11,14 @@ */ import { useAtomValue } from "@effect/atom-react"; import { + AuthDiagnosticsReadScope, USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -25,11 +27,13 @@ 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 canReadDiagnostics: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -48,11 +52,27 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { + const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); + const access = resolveUsageAccess({ + connectionPhase: presentation.connection.phase, + session: Option.getOrNull(AsyncResult.value(sessionResult)), + hasSessionError: sessionResult._tag === "Failure", + }); + if (!access.canReadDiagnostics) { + statuses.push({ + environmentId, + label: presentation.entry.target.label, + ...access, + summary: null, + }); + continue; + } const result = get(serverEnvironment.usageSummary({ environmentId, input })); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, + canReadDiagnostics: true, error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); @@ -109,13 +129,17 @@ export function useUsage(input: UsageSummaryInput): UsageView { const input = JSON.parse(windowKey) as UsageSummaryInput; for (const environment of environments) { const { environmentId } = environment; + const hasAccess = () => readEnvironmentScope(environmentId, AuthDiagnosticsReadScope); + if (!environment.canReadDiagnostics || !hasAccess()) continue; const query = serverEnvironment.usageSummary({ environmentId, input }); void runAtomCommand( appAtomRegistry, serverEnvironment.refreshUsageRates, { environmentId, input: {} }, { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); + ).finally(() => { + if (hasAccess()) appAtomRegistry.refresh(query); + }); } }, [environments, windowKey]); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 0f58554afc67..692f487fd879 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -5,6 +5,7 @@ import { AuthEnvironmentMaintainScope, AuthFilesystemReadScope, AuthFilesystemWriteScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -57,13 +58,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, - [WS_METHODS.serverRetryResourceTelemetry]: AuthEnvironmentMaintainScope, - [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, - [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetTraceDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessDiagnostics]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope, [WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthEnvironmentMaintainScope, @@ -112,7 +113,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, - [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, + [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthSourceControlWriteScope, [WS_METHODS.gitRunStackedAction]: AuthSourceControlWriteScope, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32a483ca7d..ee8e8196d12a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -16,6 +16,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DpopFailureReason, + EnvironmentAuthorizationError, EnvironmentId, EventId, GitCommandError, @@ -43,6 +44,7 @@ import { type ServerLifecycleStreamEvent, ThreadId, TurnId, + UsageDay, WS_METHODS, WsRpcGroup, EditorId, @@ -869,7 +871,7 @@ const buildAppUnderTest = (options?: { Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => Effect.succeed({ - traceFilePath: "", + traceFilePath: config.serverTracePath, scannedFilePaths: [], readAt: TEST_EPOCH, recordCount: 0, @@ -4265,6 +4267,126 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const scope of ["orchestration:read", "diagnostics:read"] as const) { + it.effect(`separates diagnostics RPC access for ${scope} sessions`, () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const { response, body } = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { + scope, + }); + assert.equal(response.status, 200); + assert.equal(body.scope, scope); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${body.access_token ?? ""}` }, + }); + const { ticket } = (yield* ticketResponse.json) as { readonly ticket: string }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const diagnosticsReads = [ + client[WS_METHODS.serverGetTraceDiagnostics]({}).pipe(Effect.asVoid), + client[WS_METHODS.serverGetProcessDiagnostics]({}).pipe(Effect.asVoid), + client[WS_METHODS.serverGetProcessResourceHistory]({ + windowMs: 60_000, + bucketMs: 10_000, + }).pipe(Effect.asVoid), + client[WS_METHODS.serverGetResourceTelemetryHistory]({ + windowMs: 60_000, + bucketMs: 10_000, + }).pipe(Effect.asVoid), + client[WS_METHODS.subscribeResourceTelemetry]({}).pipe( + Stream.runHead, + Effect.asVoid, + ), + client[WS_METHODS.serverGetUsageSummary]({ + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make("2026-09-01"), + timeZone: "UTC", + }).pipe(Effect.asVoid), + client[WS_METHODS.serverRefreshUsageRates]({}).pipe(Effect.asVoid), + ]; + for (const read of diagnosticsReads) { + if (scope === "diagnostics:read") { + yield* read; + } else { + const error = yield* Effect.flip(read); + if (!Schema.is(EnvironmentAuthorizationError)(error)) { + assert.fail(`Expected a diagnostics authorization error, got ${String(error)}`); + } + assert.equal(error.requiredScope, "diagnostics:read"); + } + } + if (scope === "orchestration:read") { + yield* client[WS_METHODS.serverGetConfig]({}); + } else { + const error = yield* Effect.flip(client[WS_METHODS.serverGetConfig]({})); + assert.equal(error._tag, "EnvironmentAuthorizationError"); + const mutationError = yield* Effect.flip( + client[WS_METHODS.serverRetryResourceTelemetry]({}), + ); + assert.equal(mutationError._tag, "EnvironmentAuthorizationError"); + } + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + + it.effect("requires maintenance and diagnostics grants before retrying telemetry", () => + Effect.gen(function* () { + let retries = 0; + yield* buildAppUnderTest({ + layers: { + nativeTelemetryClient: { + retry: Effect.sync(() => { + retries += 1; + return true; + }), + }, + }, + }); + for (const scope of [ + "environment:maintain", + "diagnostics:read", + "environment:maintain diagnostics:read", + ]) { + const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, { scope }); + assert.equal(token.response.status, 200); + const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { authorization: `Bearer ${token.body.access_token ?? ""}` }, + }); + const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`; + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const retry = client[WS_METHODS.serverRetryResourceTelemetry]({}); + if (scope === "environment:maintain diagnostics:read") { + const result = yield* retry; + assert.equal(result.accepted, true); + assert.ok(result.snapshot.health); + assert.equal(retries, 1); + } else { + const error = yield* Effect.flip(retry); + if (error._tag !== "EnvironmentAuthorizationError") { + assert.fail(`Expected an authorization error, got ${String(error)}`); + } + assert.equal( + error.requiredScope, + scope === "environment:maintain" ? "diagnostics:read" : "environment:maintain", + ); + assert.equal(retries, 0); + } + }), + ), + ); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on remote auth success responses", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4a8922d23526..82af703e2722 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -16,6 +16,7 @@ import { type AuthEnvironmentScope, AuthFilesystemReadScope, AuthOrchestrationOperateScope, + AuthEnvironmentMaintainScope, AuthOrchestrationReadScope, AuthSessionId, ClientConnectionMethod, @@ -2035,9 +2036,15 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => - observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverRetryResourceTelemetry, + resourceTelemetry.retry, + { "rpc.aggregate": "server" }, + [ + AuthEnvironmentMaintainScope, + requiredScopeForRpcMethod(WS_METHODS.serverRetryResourceTelemetry), + ], + ), [WS_METHODS.serverSignalProcess]: (input) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f94d366eaa2d..6df6993fa7f6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -17,6 +17,7 @@ import { AuthSettingsWriteScope, AuthProvidersManageScope, AuthEnvironmentMaintainScope, + AuthDiagnosticsReadScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthPreviewOperateScope, @@ -224,6 +225,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", @@ -1182,9 +1188,11 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio disabled={isCreatingPairingLink} onClick={() => setPairingScopes( - [AuthOrchestrationReadScope, AuthFilesystemReadScope].filter((scope) => - delegatableScopes.includes(scope), - ), + [ + AuthOrchestrationReadScope, + AuthFilesystemReadScope, + AuthDiagnosticsReadScope, + ].filter((scope) => delegatableScopes.includes(scope)), ) } > diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 052962b87dd6..a8c1e1e4bafe 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -12,6 +12,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { AuthEnvironmentMaintainScope, @@ -28,7 +29,7 @@ import { ensureLocalApi } from "../../localApi"; import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; +import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { primaryServerAvailableEditorsAtom, primaryServerObservabilityAtom, @@ -789,6 +790,15 @@ export function DiagnosticsSettingsPanel() { const environmentId = primaryEnvironment?.environmentId ?? null; const canMaintainEnvironment = useEnvironmentScope(environmentId, AuthEnvironmentMaintainScope); const canOpenHostEditor = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); + const session = useEnvironmentQuery( + environmentId === null ? null : environmentSession.sessionStateAtom(environmentId), + ); + const diagnosticsAccess = resolveUsageAccess({ + connectionPhase: primaryEnvironment?.connection.phase ?? "available", + session: session.data, + hasSessionError: session.error !== null, + }); + const canReadDiagnostics = diagnosticsAccess.canReadDiagnostics; const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -798,7 +808,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: {} }), ); @@ -808,7 +818,7 @@ export function DiagnosticsSettingsPanel() { isPending: isProcessPending, refresh: refreshProcesses, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processDiagnostics({ environmentId, input: {} }), ); @@ -818,7 +828,7 @@ export function DiagnosticsSettingsPanel() { isPending: isResourcePending, refresh: refreshResources, } = useEnvironmentQuery( - environmentId === null + environmentId === null || !canReadDiagnostics ? null : serverEnvironment.processResourceHistory({ environmentId, @@ -966,6 +976,20 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (!canReadDiagnostics) { + return ( + +

+ {environmentId === null + ? "Connect an environment to see diagnostics." + : diagnosticsAccess.isPending + ? "Checking diagnostics access…" + : diagnosticsAccess.error} +

+
+ ); + } + return ( diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..afbfb10b4f2a 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -109,6 +109,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 e957002115ab..876a32897448 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -102,6 +102,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], @@ -224,6 +228,7 @@ export function UsagePage() {