From 04f3a2850534aab6b2c6357a6194ececc12c7baa Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 03:11:17 +0100 Subject: [PATCH] fix(codex): accept ChatGPT Edu Plus accounts --- .../src/_internal/shared.test.ts | 42 +++++++++++++++++++ .../src/_internal/shared.ts | 40 +++++++++++++++++- .../src/client.test.ts | 31 ++++++++++++++ .../fixtures/codex-app-server-mock-peer.ts | 4 +- 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/packages/effect-codex-app-server/src/_internal/shared.test.ts b/packages/effect-codex-app-server/src/_internal/shared.test.ts index 5e87485c85d2..ba354d568701 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.test.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as CodexError from "../errors.ts"; +import * as CodexSchema from "../schema.ts"; import * as Shared from "./shared.ts"; const decodeNestedNumberPayload = Schema.decodeUnknownEffect( @@ -135,6 +136,47 @@ it.effect("passes request errors through without adding a wrapper", () => }), ); +it.effect("normalizes unrecognized plan types to unknown before decoding", () => + Effect.gen(function* () { + const account = yield* Shared.decodeOptionalPayload( + "account/read", + CodexSchema.V2GetAccountResponse, + { + account: { + type: "chatgpt", + email: "edu@example.com", + planType: "edu_plus", + }, + requiresOpenaiAuth: false, + }, + ); + + assert.deepEqual(account.account, { + type: "chatgpt", + email: "edu@example.com", + planType: "unknown", + }); + + const knownPlan = yield* Shared.decodeOptionalPayload( + "account/read", + CodexSchema.V2GetAccountResponse, + { + account: { + type: "chatgpt", + email: "plus@example.com", + planType: "plus", + }, + requiresOpenaiAuth: false, + }, + ); + + assert.equal(knownPlan.account?.type, "chatgpt"); + if (knownPlan.account?.type === "chatgpt") { + assert.equal(knownPlan.account.planType, "plus"); + } + }), +); + it.effect("retains the full notification payload decode cause chain", () => Effect.gen(function* () { const error = yield* Shared.decodeNotificationPayload( diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..2fb916145259 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -17,6 +17,42 @@ export const JsonRpcResponseEnvelope = Schema.Struct({ error: Schema.optional(JsonRpcError), }); +// Plan types emitted by the running codex binary can be newer than the pinned +// protocol schema (e.g. "edu_plus"). Upstream maps unrecognized plans to +// "unknown" via #[serde(other)]; mirror that so account payloads still decode. +const KNOWN_PLAN_TYPES = new Set([ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_usage_based", + "business", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "unknown", +]); + +export const normalizeUnknownPlanTypes = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalizeUnknownPlanTypes); + } + if (typeof value !== "object" || value === null) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [ + key, + key === "planType" && typeof child === "string" && !KNOWN_PLAN_TYPES.has(child) + ? "unknown" + : normalizeUnknownPlanTypes(child), + ]), + ); +}; + export const decodeOptionalPayload = ( method: string, schema: Schema.Codec | undefined, @@ -31,7 +67,9 @@ export const decodeOptionalPayload = ( ); } - return Schema.decodeUnknownEffect(schema)(raw).pipe( + return Schema.decodeUnknownEffect(schema)( + typeof raw === "object" && raw !== null ? normalizeUnknownPlanTypes(raw) : raw, + ).pipe( Effect.mapError((error) => CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error), ), diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 3830c5fc5f6f..1dfb495eaf5b 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -123,6 +123,37 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { ]); }), ); + it.effect("decodes account plans the pinned protocol schema does not know", () => + Effect.gen(function* () { + const handle = yield* makeHandle({ + CODEX_APP_SERVER_TEST_ACCOUNT_PLAN_TYPE: "edu_plus", + }); + const scope = yield* Scope.make(); + const clientLayer = CodexClient.layerChildProcess(handle); + const context = yield* Layer.buildWithScope(clientLayer, scope); + + const account = yield* Effect.gen(function* () { + const client = yield* CodexClient.CodexAppServerClient; + yield* client.request("initialize", { + clientInfo: { + name: "effect-codex-app-server-test", + title: "Effect Codex App Server Test", + version: "0.0.0", + }, + capabilities: { + experimentalApi: true, + optOutNotificationMethods: null, + }, + }); + return yield* client.request("account/read", {}); + }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); + + assert.equal(account.account?.type, "chatgpt"); + if (account.account?.type === "chatgpt") { + assert.equal(account.account.planType, "unknown"); + } + }), + ); it.effect("drains child stderr so large diagnostics cannot block protocol responses", () => Effect.gen(function* () { const handle = yield* makeHandle({ diff --git a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts index 3f2a213d38c7..a6f58f4df11c 100644 --- a/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts +++ b/packages/effect-codex-app-server/test/fixtures/codex-app-server-mock-peer.ts @@ -71,11 +71,13 @@ const handleMethod = (message: Record) => { return; } case "account/read": { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone mock peer process has no Effect runtime. + const planType = process.env.CODEX_APP_SERVER_TEST_ACCOUNT_PLAN_TYPE ?? "plus"; respond(message.id as number | string, { account: { type: "chatgpt", email: "mock@example.com", - planType: "plus", + planType, }, requiresOpenaiAuth: false, });