Skip to content
Open
35 changes: 25 additions & 10 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -134,10 +135,12 @@ export function UsageRouteScreen() {
contentContainerClassName="gap-6 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
refreshControl={
<RefreshControl
refreshing={showingLimits ? limits.refreshing : refreshingUsage}
onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
/>
showingLimits || canReadDiagnostics ? (
<RefreshControl
refreshing={showingLimits ? limits.refreshing : refreshingUsage}
onRefresh={showingLimits ? () => void limits.refresh() : refreshWindow}
/>
) : undefined
}
>
<SegmentedControl options={TAB_OPTIONS} selected={tab} onSelect={setTab} role="tab" />
Expand All @@ -164,11 +167,6 @@ export function UsageRouteScreen() {
className="w-36"
/>
</View>
<UsageCoverageNotice
environments={environments}
merged={merged}
isPartial={isPartial}
/>
{isPending ? (
<Text className="py-16 text-center text-base text-foreground-muted">
Scanning provider transcripts…
Expand All @@ -177,8 +175,25 @@ export function UsageRouteScreen() {
<Text className="py-16 text-center text-base text-foreground-muted">
Connect an environment to see usage.
</Text>
) : !canReadDiagnostics ? (
<View className="gap-2 py-16">
{environments.map((environment) => (
<Text
key={environment.environmentId}
className="text-center text-base text-foreground-muted"
>
{environments.length > 1 ? `${environment.label}: ` : null}
{environment.error}
</Text>
))}
</View>
) : (
<>
<UsageCoverageNotice
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
environments={environments}
merged={merged}
isPartial={isPartial}
/>
Comment thread
juliusmarminge marked this conversation as resolved.
<ChartCard
merged={merged}
days={chartDays}
Expand Down Expand Up @@ -516,7 +531,7 @@ function UsageCoverageNotice(props: {
) : null}
{failed.map((environment) => (
<Text key={environment.environmentId} className="text-sm text-foreground-muted">
{environment.label} could not report usage.
{environment.label}: {environment.error}
</Text>
))}
{stale.map((environment) => (
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Expand All @@ -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)),
});
Expand Down Expand Up @@ -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]);

Expand Down
17 changes: 9 additions & 8 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AuthEnvironmentMaintainScope,
AuthFilesystemReadScope,
AuthFilesystemWriteScope,
AuthDiagnosticsReadScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -110,7 +111,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,
Expand Down
124 changes: 123 additions & 1 deletion apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CommandId,
DEFAULT_SERVER_SETTINGS,
type DpopFailureReason,
EnvironmentAuthorizationError,
EnvironmentId,
EventId,
GitCommandError,
Expand All @@ -42,6 +43,7 @@ import {
ResolvedKeybindingRule,
ThreadId,
TurnId,
UsageDay,
WS_METHODS,
WsRpcGroup,
EditorId,
Expand Down Expand Up @@ -856,7 +858,7 @@ const buildAppUnderTest = (options?: {
Layer.mock(TraceDiagnostics.TraceDiagnostics)({
read: () =>
Effect.succeed({
traceFilePath: "",
traceFilePath: config.serverTracePath,
scannedFilePaths: [],
readAt: TEST_EPOCH,
recordCount: 0,
Expand Down Expand Up @@ -4251,6 +4253,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();
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type AuthEnvironmentScope,
AuthFilesystemReadScope,
AuthOrchestrationOperateScope,
AuthEnvironmentMaintainScope,
AuthOrchestrationReadScope,
AuthSessionId,
ClientConnectionMethod,
Expand Down Expand Up @@ -2029,9 +2030,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",
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
AuthSettingsWriteScope,
AuthProvidersManageScope,
AuthEnvironmentMaintainScope,
AuthDiagnosticsReadScope,
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthPreviewOperateScope,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)),
)
}
>
Expand Down
Loading
Loading