Skip to content
Open
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
42 changes: 42 additions & 0 deletions packages/effect-codex-app-server/src/_internal/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
40 changes: 39 additions & 1 deletion packages/effect-codex-app-server/src/_internal/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High _internal/shared.ts:49

normalizeUnknownPlanTypes changes legitimate opaque payload data such as { planType: "custom" } to { planType: "unknown" } before handlers receive it. Because decodeOptionalPayload applies this recursively to every decoded payload, fields like tool arguments, structuredContent, realtime items, and web-search results are corrupted; restrict normalization to the actual account-plan fields or methods.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/effect-codex-app-server/src/_internal/shared.ts around line 49:

`normalizeUnknownPlanTypes` changes legitimate opaque payload data such as `{ planType: "custom" }` to `{ planType: "unknown" }` before handlers receive it. Because `decodeOptionalPayload` applies this recursively to every decoded payload, fields like tool `arguments`, `structuredContent`, realtime items, and web-search results are corrupted; restrict normalization to the actual account-plan fields or methods.

? "unknown"
: normalizeUnknownPlanTypes(child),
Comment on lines +49 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve opaque planType fields outside account payloads

When a decoded response or notification contains opaque tool data—such as MCP results or dynamic-tool arguments with an application field like planType: "premium"—this key-only recursive walk silently rewrites it to "unknown". Those values are represented by Schema.Unknown in the generated protocol and should pass through unchanged, but decodeOptionalPayload is shared by every method, so unrelated thread and tool payloads are corrupted; restrict normalization to the actual account and rate-limit plan paths.

Useful? React with 👍 / 👎.

]),
);
};

export const decodeOptionalPayload = <A, I>(
method: string,
schema: Schema.Codec<A, I> | undefined,
Expand All @@ -31,7 +67,9 @@ export const decodeOptionalPayload = <A, I>(
);
}

return Schema.decodeUnknownEffect(schema)(raw).pipe(
return Schema.decodeUnknownEffect(schema)(
typeof raw === "object" && raw !== null ? normalizeUnknownPlanTypes(raw) : raw,
).pipe(
Comment on lines +70 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid cloning every decoded protocol payload

Because every object-shaped call to decodeOptionalPayload takes this branch, the normalizer recursively walks and reconstructs complete thread/list, thread/read, and notification payloads even though almost none contain an account plan. Large thread snapshots therefore incur an additional full traversal and allocation pass before schema decoding, while frequent notifications create unnecessary object churn; gate normalization to plan-bearing methods or handle the fallback in the relevant plan schemas instead.

AGENTS.md reference: AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

Effect.mapError((error) =>
CodexError.CodexAppServerRequestError.invalidPayload(method, "decode-payload", error),
),
Expand Down
31 changes: 31 additions & 0 deletions packages/effect-codex-app-server/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ const handleMethod = (message: Record<string, unknown>) => {
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,
});
Expand Down
Loading