Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ const WSL_FORWARDED_ENV_NAMES = [
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
"OTEL_TRACES_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_LOGS_EXPORTER",
] as const;

const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/src/features/connection/ConnectionFormField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("react-native", () => ({
View: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../../components/AppText", () => ({
AppText: ({
accessibilityElementsHidden,
importantForAccessibility,
children,
}: {
accessibilityElementsHidden?: boolean;
importantForAccessibility?: string;
children: ReactNode;
}) => (
<span
aria-hidden={
accessibilityElementsHidden || importantForAccessibility === "no-hide-descendants"
}
>
{children}
</span>
),
AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => (
<input aria-label={accessibilityLabel} />
),
}));

import { ConnectionFormField } from "./ConnectionFormField";

describe("ConnectionFormField accessibility", () => {
it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => {
const markup = renderToStaticMarkup(
<ConnectionFormField label={label} placeholder="Not an accessible name" />,
);

expect(markup).toContain(`<span aria-hidden="true">${label}</span>`);
expect(markup).toContain(`<input aria-label="${label}"/>`);
});
});
10 changes: 7 additions & 3 deletions apps/mobile/src/features/connection/ConnectionFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { View } from "react-native";
import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText";
import { cn } from "../../lib/cn";

type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
type ConnectionFormFieldProps = Omit<AppTextInputProps, "accessibilityLabel" | "className"> & {
readonly label: string;
readonly className?: string;
};
Expand All @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & {
export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) {
return (
<View collapsable={false} className={cn("gap-1.5", className)}>
<AppText className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
<AppText
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"
>
{label}
</AppText>
<AppTextInput
accessibilityLabel={label}
{...inputProps}
accessibilityLabel={label}
className="rounded-[14px] px-4 py-3.5"
/>
</View>
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts";
import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native";
import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage";
import {
isCompatibleUsageContractVersion,
isModelCostUnknown,
Expand All @@ -15,6 +16,7 @@ import {
formatHourShort,
formatPercent,
formatTokens,
formatUsageContractMismatch,
formatUsd,
makeWindow,
} from "@t3tools/shared/usageFormat";
Expand Down Expand Up @@ -101,9 +103,7 @@ export function UsageRouteScreen() {
);
const isFocused = useIsFocused();
const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
const cursorAccessEnvironments = selectedEnvironments.filter(
(environment) => environment.needsCursorKeychainAccess,
);
const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments);
const refreshAfterCursorEnable = () => {
void refresh();
void limits.refreshAfterEnable();
Expand Down Expand Up @@ -753,7 +753,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
environment.summary &&
!isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
) {
return "Older server · excluded from usage totals";
return formatUsageContractMismatch(environment.label, {
direction:
environment.summary.contractVersion < USAGE_CONTRACT_VERSION
? "serverBehind"
: "clientBehind",
});
}
if (!environment.isConnected)
return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/provider/cursorCredentialStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { assert, describe, it } from "@effect/vitest";

import { makeCachedCursorAccessTokenReader } from "./cursorCredentialStore.ts";
import {
CursorKeychainTimeoutError,
makeCachedCursorAccessTokenReader,
} from "./cursorCredentialStore.ts";

describe("Cursor Keychain reader", () => {
it("shares concurrent reads and rechecks after the cache expires", async () => {
Expand All @@ -19,4 +22,25 @@ describe("Cursor Keychain reader", () => {
time = 5 * 60_000;
assert.strictEqual(await read(), "token-2");
});

it("gives up on an unanswered prompt and reuses it on the next read", async () => {
let reads = 0;
let allow: (token: string) => void = () => {};
const read = makeCachedCursorAccessTokenReader(
() => {
reads++;
return new Promise((resolve) => {
allow = resolve;
});
},
() => 0,
1,
);
const error = await read().catch((cause: unknown) => cause);
assert.instanceOf(error, CursorKeychainTimeoutError);
const retry = read();
allow("token");
assert.strictEqual(await retry, "token");
assert.strictEqual(reads, 1);
});
});
30 changes: 26 additions & 4 deletions apps/server/src/provider/cursorCredentialStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,47 @@ const CACHE_MS = 5 * 60_000;

const requireForKeyring = NodeModule.createRequire(import.meta.url);

/** Share one Keychain request across usage history and limits in this server process. */
/** Rejected when nobody answers the macOS Keychain prompt in time. */
export class CursorKeychainTimeoutError extends Error {
constructor() {
super("Timed out waiting for Keychain access.");
}
}

/**
* Share one Keychain request across usage history and limits in this server process.
*
* macOS shows the access prompt on the server's own screen, which a remote
* client cannot answer, so callers give up after `timeoutMs`. The read stays in
* flight: the next call reuses it instead of stacking a second prompt, and picks
* up the token once someone allows access.
*/
export function makeCachedCursorAccessTokenReader(
read: () => Promise<string | null>,
now: () => number = Date.now,
timeoutMs = 30_000,
): () => Promise<string | null> {
let cached: { token: string; until: number } | null = null;
let pending: Promise<string | null> | null = null;
return () => {
if (cached && cached.until > now()) return Promise.resolve(cached.token);
if (pending) return pending;
pending = read()
pending ??= read()
.then((token) => {
cached = token ? { token, until: now() + CACHE_MS } : null;
return token;
})
.finally(() => {
pending = null;
});
return pending;
const deadline = AbortSignal.timeout(timeoutMs);
return Promise.race([
pending,
new Promise<never>((_, reject) => {
deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), {
once: true,
});
}),
]);
};
}

Expand Down
49 changes: 41 additions & 8 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,46 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
}

describe("UsageService", () => {
it.live("omits Cursor account usage when no file login is saved", () =>
Effect.gen(function* () {
const { settings, home } = yield* setup;
for (const platform of ["linux", "win32", "darwin"] as const) {
const service = yield* UsageService.make.pipe(
Effect.provide(
serviceLayers({
prefix: `usage-service-cursor-no-login-${platform}`,
home,
settings,
platform,
environment: { AGENT_CLI_CREDENTIAL_STORE: "file" },
}),
),
);
const summary = yield* service.readSummary(WINDOW);
assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor"));
}
}).pipe(Effect.scoped),
);

it.live("keeps Cursor credential errors visible when a saved login cannot be read", () =>
Effect.gen(function* () {
const { settings, home } = yield* setup;
const authPath = NodePath.join(home, "config", "cursor", "auth.json");
yield* Effect.promise(async () => {
await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true });
await NodeFSP.writeFile(authPath, "invalid json");
});
const service = yield* UsageService.make.pipe(
Effect.provide(
serviceLayers({ prefix: "usage-service-cursor-invalid-login", home, settings }),
),
);
const summary = yield* service.readSummary(WINDOW);
const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor");
assert.strictEqual(cursor?.message, "Cursor credentials could not be read.");
}).pipe(Effect.scoped),
);

it.live("does not read the macOS Cursor Keychain before account usage is enabled", () =>
Effect.gen(function* () {
const { settings, home } = yield* setup;
Expand Down Expand Up @@ -233,10 +273,7 @@ describe("UsageService", () => {
const summary = yield* service.readSummary(WINDOW);
assert.strictEqual(summary.buckets[0]?.provider, "opencode");
assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor"));
assert.strictEqual(
summary.sources.find((source) => source.fingerprint.provider === "cursor")?.status,
"missing",
);
assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor"));
assert.strictEqual(
summary.buckets[0]?.sourcePath,
yield* Effect.promise(() => NodeFSP.realpath(root)),
Expand All @@ -247,10 +284,6 @@ describe("UsageService", () => {
?.distinctSessions,
1,
);
assert.include(
summary.sources.find((source) => source.fingerprint.provider === "cursor")?.message ?? "",
"Cursor account history needs a Cursor CLI login",
);
}).pipe(Effect.scoped),
);

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,8 @@ export const make = Effect.gen(function* () {
cursorUntilMs,
),
);
// No saved login means there is no account source to report, not a setup error.
if (account.missing && account.error === null) return scanned;
if (account.accountKey !== null && account.error === null && !account.missing) {
// The same account includes CLI and desktop history from every machine.
// A stable remote fingerprint prevents connected environments counting it twice.
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/usage/cursorUsageReader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { assert, describe, it } from "@effect/vitest";

import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts";
import { readCursorAccountUsage } from "./cursorUsageReader.ts";

describe("readCursorAccountUsage", () => {
it("asks for Keychain approval when the prompt goes unanswered", async () => {
const result = await readCursorAccountUsage(
{ kind: "keychain" },
0,
1,
() => Promise.reject(new Error("no network expected")),
() => Promise.reject(new CursorKeychainTimeoutError()),
);
assert.deepStrictEqual(result, {
accountKey: null,
records: [],
missing: false,
error: "Allow Keychain access on the Mac running T3 Code, then refresh.",
});
});
});
9 changes: 7 additions & 2 deletions apps/server/src/usage/cursorUsageReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import * as NodeCrypto from "node:crypto";
import * as NodeTimersPromises from "node:timers/promises";

import type { UsageRecord } from "./usageTranscripts.ts";
import { readMacCursorAccessToken } from "../provider/cursorCredentialStore.ts";
import {
CursorKeychainTimeoutError,
readMacCursorAccessToken,
} from "../provider/cursorCredentialStore.ts";

function object(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
Expand Down Expand Up @@ -86,7 +89,9 @@ export async function readCursorAccountUsage(
? null
: typeof credentialSource === "string"
? "Cursor credentials could not be read."
: "Cursor Keychain credentials could not be read.",
: cause instanceof CursorKeychainTimeoutError
? "Allow Keychain access on the Mac running T3 Code, then refresh."
: "Cursor Keychain credentials could not be read.",
};
}
if (typeof accessToken !== "string" || !accessToken) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
side="left"
collapsible="offcanvas"
data-app-sidebar=""
role="navigation"
aria-label={isOnSettings ? "Settings" : "Threads"}
resizable={{
maxWidth: sidebarMaximumWidth,
minWidth: THREAD_SIDEBAR_MIN_WIDTH,
Expand Down
Loading
Loading