Skip to content
Source
Sign inSign up

Identify.ts

341 lines11.4 KB

1import * as SecureRandom from "effect/crypto/SecureRandom";
2import * as Effect from "effect/Effect";
3import * as Base64Url from "effect/encoding/Base64Url";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as PlatformError from "effect/PlatformError";
8import * as Result from "effect/Result";
9import * as Schema from "effect/Schema";
10​
11import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
12import * as HostProcess from "@t3tools/shared/HostProcess";
13import { hashTelemetryId } from "@t3tools/shared/telemetryId";
14import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
15import { CLOUD_LINKED_USER_ID } from "../cloud/config.ts";
16import * as ServerConfig from "../config.ts";
17​
18/**
19 * Codex writes `tokens` only for ChatGPT logins and omits the key for API-key,
20 * agent-identity, and personal-access-token logins, so its absence is a
21 * supported install and not a malformed file. `account_id` is the ChatGPT
22 * workspace, which a whole Team shares; the `id_token` names the person.
23 */
24const CodexAuthJsonSchema = Schema.Struct({
25 tokens: Schema.optionalKey(
26 Schema.Struct({
27 account_id: Schema.String,
28 id_token: Schema.optionalKey(Schema.String),
29 }),
30 ),
31});
32​
33/** `userID` is random per machine. `oauthAccount` is present only while Claude Code is logged in. */
34const ClaudeJsonSchema = Schema.Struct({
35 userID: Schema.optionalKey(Schema.String),
36 oauthAccount: Schema.optionalKey(
37 Schema.Struct({
38 accountUuid: Schema.optionalKey(Schema.String),
39 }),
40 ),
41});
42​
43// `sub` names the login method (`google-oauth2|…`), so one person can have several.
44// `user_id` (`user-…`) is the OpenAI account and is the same for every login.
45const IdTokenClaims = Schema.Struct({
46 "https://api.openai.com/auth": Schema.Struct({ user_id: Schema.NonEmptyString }),
47});
48const decodeIdTokenClaims = Schema.decodeUnknownOption(Schema.fromJsonString(IdTokenClaims));
49​
50/**
51 * Who an install belongs to, from strongest to weakest. `connect` is the T3
52 * Connect account. `openai` and `claude` are provider accounts. `device` is a
53 * client device that connected.
54 */
55export const TelemetryOwnerSource = Schema.Literals(["connect", "openai", "claude", "device"]);
56export type TelemetryOwnerSource = typeof TelemetryOwnerSource.Type;
57​
58export interface TelemetryOwner {
59 readonly source: TelemetryOwnerSource;
60 /** `hashTelemetryId` of the source's raw id. */
61 readonly ownerId: string;
63​
64export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]);
65export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type;
66​
67class TelemetryIdentityReadError extends Schema.TaggedError<TelemetryIdentityReadError>()(
68 "TelemetryIdentityReadError",
69 {
70 source: TelemetryIdentitySource,
71 filePath: Schema.String,
72 cause: Schema.Defect(),
73 },
74) {
75 override get message(): string {
76 return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`;
77 }
79​
80class TelemetryIdentityDecodeError extends Schema.TaggedError<TelemetryIdentityDecodeError>()(
81 "TelemetryIdentityDecodeError",
82 {
83 source: Schema.Literals(["codex", "claude"]),
84 filePath: Schema.String,
85 cause: Schema.Defect(),
86 },
87) {
88 override get message(): string {
89 return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`;
90 }
92​
93export class TelemetryAnonymousIdGenerationError extends Schema.TaggedError<TelemetryAnonymousIdGenerationError>()(
94 "TelemetryAnonymousIdGenerationError",
95 {
96 source: Schema.Literal("anonymous"),
97 filePath: Schema.String,
98 cause: Schema.Defect(),
99 },
100) {
101 override get message(): string {
102 return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`;
103 }
105​
106export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedError<TelemetryAnonymousIdPersistenceError>()(
107 "TelemetryAnonymousIdPersistenceError",
108 {
109 source: Schema.Literal("anonymous"),
110 filePath: Schema.String,
111 cause: Schema.Defect(),
112 },
113) {
114 override get message(): string {
115 return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`;
116 }
118​
119type TelemetryIdentityError =
120 | TelemetryIdentityReadError
121 | TelemetryIdentityDecodeError
122 | TelemetryAnonymousIdGenerationError
123 | TelemetryAnonymousIdPersistenceError;
124​
125const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema));
126const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema));
127​
128function isNotFoundError(error: PlatformError.PlatformError): boolean {
129 return error.reason._tag === "NotFound";
131​
132const getTelemetryIdentityCauseAnnotations = (cause: unknown) => {
133 if (cause instanceof PlatformError.PlatformError) {
134 return {
135 causeKind: "platform",
136 platformReason: cause.reason._tag,
137 };
138 }
139 if (cause instanceof Schema.SchemaError) {
140 return { causeKind: "schema" };
141 }
142 return { causeKind: "other" };
143};
144​
145const logTelemetryIdentityError = (error: TelemetryIdentityError) =>
146 Effect.logWarning(error.message).pipe(
147 Effect.annotateLogs({
148 errorTag: error._tag,
149 source: error.source,
150 ...("filePath" in error ? { filePath: error.filePath } : {}),
151 ...getTelemetryIdentityCauseAnnotations(error.cause),
152 ...(error.stack === undefined ? {} : { errorStack: error.stack }),
153 }),
154 );
155​
156const readIdentityFile = (
157 fileSystem: FileSystem.FileSystem,
158 source: TelemetryIdentitySource,
159 filePath: string,
160) =>
161 fileSystem.readFileString(filePath).pipe(
162 Effect.asSome,
163 Effect.catchTags({
164 PlatformError: (cause) =>
165 isNotFoundError(cause)
166 ? Effect.succeed(Option.none<string>())
167 : Effect.fail(
168 new TelemetryIdentityReadError({
169 source,
170 filePath,
171 cause,
172 }),
173 ),
174 }),
175 );
176​
177/**
178 * The OpenAI account id in a ChatGPT id_token, read without verifying it:
179 * analytics needs only a stable id. Codex CLI and T3's own sign-in both use it.
180 */
181export const openAiUserId = (idToken: string) => {
182 const payload = Base64Url.decodeString(idToken.split(".")[1] ?? "");
183 return Result.isSuccess(payload)
184 ? Option.map(
185 decodeIdTokenClaims(payload.success),
186 (claims) => claims["https://api.openai.com/auth"].user_id,
187 )
188 : Option.none<string>();
189};
190​
191const readJsonIdentityFile = Effect.fn("TelemetryIdentity.readJsonIdentityFile")(function* <A>(
192 source: "codex" | "claude",
193 filePath: string,
194 decode: (encoded: string) => Effect.Effect<A, Schema.SchemaError>,
195) {
196 const fileSystem = yield* FileSystem.FileSystem;
197 const encoded = yield* readIdentityFile(fileSystem, source, filePath);
198 if (Option.isNone(encoded)) {
199 return Option.none<A>();
200 }
201 return yield* decode(encoded.value).pipe(
202 Effect.asSome,
203 Effect.mapError((cause) => new TelemetryIdentityDecodeError({ source, filePath, cause })),
204 );
205});
206​
207// A missing or unreadable file only removes that owner; it never blocks the others.
208const skipOnError = <A, R>(
209 effect: Effect.Effect<
210 Option.Option<A>,
211 TelemetryIdentityReadError | TelemetryIdentityDecodeError,
212 R
213 >,
214) =>
215 effect.pipe(
216 Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none<A>()))),
217 );
218​
219const upsertAnonymousId = Effect.gen(function* () {
220 const fileSystem = yield* FileSystem.FileSystem;
221 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
222​
223 const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath);
224 if (Option.isSome(existing)) {
225 return existing.value;
226 }
227​
228 const anonymousId = yield* SecureRandom.SecureRandom.pipe(
229 Effect.flatMap((crypto) => crypto.uuidV4()),
230 Effect.mapError(
231 (cause) =>
232 new TelemetryAnonymousIdGenerationError({
233 source: "anonymous",
234 filePath: anonymousIdPath,
235 cause,
236 }),
237 ),
238 );
239 yield* writeFileStringAtomically({ filePath: anonymousIdPath, contents: anonymousId }).pipe(
240 Effect.mapError(
241 (cause) =>
242 new TelemetryAnonymousIdPersistenceError({
243 source: "anonymous",
244 filePath: anonymousIdPath,
245 cause,
246 }),
247 ),
248 );
249​
250 return anonymousId;
251});
252​
253const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(function* (
254 homeDirectory: string,
255) {
256 const path = yield* Path.Path;
257 const codexTokens = yield* readJsonIdentityFile(
258 "codex",
259 path.join(homeDirectory, ".codex", "auth.json"),
260 decodeCodexAuthJson,
261 ).pipe(
262 skipOnError,
263 Effect.map(Option.flatMap((authJson) => Option.fromUndefinedOr(authJson.tokens))),
264 );
265 const claudeJson = yield* readJsonIdentityFile(
266 "claude",
267 path.join(homeDirectory, ".claude.json"),
268 decodeClaudeJson,
269 ).pipe(skipOnError);
270 return { codexTokens, claudeJson };
271});
272​
273/**
274 * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id.
275 * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install.
276 * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through;
277 * only unreadable or malformed files warn.
278 */
279export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* (
280 homeDirectory: string,
281) {
282 const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory);
283 const installId = yield* upsertAnonymousId.pipe(
284 Effect.map(hashTelemetryId),
285 Effect.catch((error) => logTelemetryIdentityError(error).pipe(Effect.as(null))),
286 );
287 const providerId = Option.orElse(
288 Option.map(codexTokens, (tokens) => tokens.account_id),
289 () => Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.userID)),
290 );
291 return {
292 distinctId: Option.isSome(providerId) ? hashTelemetryId(providerId.value) : installId,
293 installId,
294 };
295});
296​
297export const getTelemetryIdentity = Effect.flatMap(
298 HostProcess.HomeDirectory,
299 getTelemetryIdentityForHome,
300);
301​
302/**
303 * Owners the server can read without a client: the linked T3 Connect account
304 * and the provider logins under `homeDirectory`. Clients add `connect` and
305 * `device` owners when they connect.
306 */
307export const getServerOwnersForHome = Effect.fn("getServerOwnersForHome")(function* (
308 homeDirectory: string,
309) {
310 const secrets = yield* ServerSecretStore.ServerSecretStore;
311 const connectUser = yield* secrets.get(CLOUD_LINKED_USER_ID).pipe(
312 Effect.map(Option.map((bytes) => new TextDecoder().decode(bytes))),
313 Effect.catch((cause) =>
314 Effect.logWarning("Failed to read the linked T3 Connect account for telemetry", {
315 errorTag: cause._tag,
316 }).pipe(Effect.as(Option.none<string>())),
317 ),
318 );
319 const { codexTokens, claudeJson } = yield* readProviderLogins(homeDirectory);
320​
321 const raw: ReadonlyArray<readonly [TelemetryOwnerSource, Option.Option<string>]> = [
322 ["connect", connectUser],
323 [
324 "openai",
325 Option.flatMap(codexTokens, (tokens) =>
326 Option.flatMap(Option.fromUndefinedOr(tokens.id_token), openAiUserId),
327 ),
328 ],
329 [
330 "claude",
331 Option.flatMap(claudeJson, (json) => Option.fromUndefinedOr(json.oauthAccount?.accountUuid)),
332 ],
333 ];
334 return raw.flatMap(([source, value]): ReadonlyArray<TelemetryOwner> =>
335 Option.isSome(value) && value.value.trim() !== ""
336 ? [{ source, ownerId: hashTelemetryId(value.value) }]
337 : [],
338 );
339});
340​
341export const getServerOwners = Effect.flatMap(HostProcess.HomeDirectory, getServerOwnersForHome);