feat(oauth): accept full ChatGPT session JSON for Codex manual import (#6636) (#7725)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 14:37:15 -03:00
committed by GitHub
parent 7951b60bc3
commit 2741cc5a66
8 changed files with 424 additions and 53 deletions

View File

@@ -0,0 +1 @@
- feat(oauth): accept the full ChatGPT session JSON (not just a bare access token) when pasting Codex credentials manually or via `POST /api/oauth/codex/import-token` (#6636)

View File

@@ -263,7 +263,8 @@
"src/lib/usage/usageHistory.ts": 988,
"_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.",
"_rebaseline_2026_07_18_7399_xai_oauth_modal": "PR #7399 (xAI OAuth PKCE) own growth: OAuthModal.tsx 993->998 (+5 = provider entry + PKCE flow branch wiring at the existing provider-switch chokepoint; the provider logic itself lives in src/lib/oauth/providers/xai-oauth.ts, new leaf). Third irreducible wiring bump on this modal (969->989->993->998); structural shrink tracked in #3501.",
"src/shared/components/OAuthModal.tsx": 998,
"_rebaseline_2026_07_19_6636_codex_session_json": "#6636 own growth: OAuthModal.tsx 998->1030 (gate units, split(\"\\n\").length incl. trailing newline; +32 = session-JSON paste branch for handleManualSubmit plus a shared submitCodexAccessToken() helper extracted from the pre-existing bare-JWT branch, mirroring the #5203 oauthBlobSubmit.ts extraction precedent; the normalizer logic itself lives in the new src/lib/oauth/utils/codexSessionImport.ts leaf module, not here). Fourth irreducible wiring bump on this modal (969->989->993->998->1030); structural shrink tracked in #3501.",
"src/shared/components/OAuthModal.tsx": 1030,
"src/shared/components/RequestLoggerV2.tsx": 1629,
"src/shared/components/analytics/charts.tsx": 1558,
"_rebaseline_2026_07_10_6318_omp_letta": "PR #6318 (@hamsa0x7, omp+letta CLI integrations) own growth: cliTools.ts (+53 = 2 registry entries incl. omp docsUrl) and cliRuntime.ts (+18 = runtime-detection wiring for the 2 new tools). Cohesive registry/wiring growth at the existing chokepoints; scope reduced from the original 5 tools (pi/codewhale/jcode shipped separately).",

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
import { createProviderConnection } from "@/models";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
@@ -18,15 +19,79 @@ import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/
* expiry (forcing re-auth) instead of attempting a refresh-token exchange —
* see open-sse/executors/codex.ts.
*
* Body: `{ accessToken: string, name?: string }`
* Body: `{ accessToken: string, name?: string }` OR, defense-in-depth for
* non-UI/API callers (#6636), the full session JSON object copied from
* `chatgpt.com/api/auth/session` under `{ session: {...} }`.
*
* Inspired-by: https://github.com/decolua/9router/pull/1290
*/
const bodySchema = z.object({
accessToken: z.string().trim().min(1, "accessToken is required"),
name: z.string().trim().min(1).optional(),
});
const bodySchema = z.union([
z.object({
accessToken: z.string().trim().min(1, "accessToken is required"),
name: z.string().trim().min(1).optional(),
}),
z.object({
session: z.record(z.string(), z.unknown()),
name: z.string().trim().min(1).optional(),
}),
]);
type ResolvedBody = { accessToken: string; name?: string };
/** Resolve either request-body shape to a flat `{ accessToken, name }` pair. */
function resolveAccessToken(
parsed: z.infer<typeof bodySchema>
): { ok: true; resolved: ResolvedBody } | { ok: false; error: string } {
if ("accessToken" in parsed) {
return { ok: true, resolved: { accessToken: parsed.accessToken, name: parsed.name } };
}
const result = parseCodexSessionJson(parsed.session);
if (!result.ok) return { ok: false, error: result.error };
return { ok: true, resolved: { accessToken: result.session.accessToken, name: parsed.name } };
}
/**
* Parse + validate the request body (JSON parse, Zod schema, then the
* accessToken/session-JSON union resolution). Returns either the resolved
* `{ accessToken, name }` pair or a ready-to-return 400 error response —
* keeps POST's own branch count flat as the accepted body shapes grow (#6636).
*/
async function parseRequestBody(
request: Request
): Promise<{ ok: true; resolved: ResolvedBody } | { ok: false; response: NextResponse }> {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return {
ok: false,
response: NextResponse.json(buildErrorBody(400, "Invalid or empty JSON body"), {
status: 400,
}),
};
}
const parsed = bodySchema.safeParse(rawBody);
if (!parsed.success) {
return {
ok: false,
response: NextResponse.json(
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid request body"),
{ status: 400 }
),
};
}
const resolved = resolveAccessToken(parsed.data);
if (!resolved.ok) {
return {
ok: false,
response: NextResponse.json(buildErrorBody(400, resolved.error), { status: 400 }),
};
}
return { ok: true, resolved: resolved.resolved };
}
async function requireAuth(request: Request): Promise<NextResponse | null> {
if (!(await isAuthRequired(request))) return null;
@@ -38,22 +103,10 @@ export async function POST(request: Request) {
const authResponse = await requireAuth(request);
if (authResponse) return authResponse;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(buildErrorBody(400, "Invalid or empty JSON body"), { status: 400 });
}
const body = await parseRequestBody(request);
if (!body.ok) return body.response;
const parsed = bodySchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json(
buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid request body"),
{ status: 400 }
);
}
const { accessToken, name } = parsed.data;
const { accessToken, name } = body.resolved;
const info = extractCodexAccountInfo(accessToken);
if (!info.email && !info.chatgptAccountId && !name) {

View File

@@ -58,10 +58,7 @@ function decodeJwtPayload(jwt: unknown): Record<string, unknown> | null {
const missingPadding =
(BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
const padded = base64 + "=".repeat(missingPadding);
return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record<
string,
unknown
>;
return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
@@ -82,21 +79,31 @@ export function extractCodexAccountInfo(idToken: string): {
} {
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const chatgpt =
(payload["https://api.openai.com/auth"] as Record<string, unknown>) || {};
const chatgpt = (payload["https://api.openai.com/auth"] as Record<string, unknown>) || {};
return {
email: typeof payload.email === "string" ? payload.email : undefined,
chatgptAccountId:
typeof chatgpt.chatgpt_account_id === "string"
? chatgpt.chatgpt_account_id
: undefined,
typeof chatgpt.chatgpt_account_id === "string" ? chatgpt.chatgpt_account_id : undefined,
chatgptPlanType:
typeof chatgpt.chatgpt_plan_type === "string"
? chatgpt.chatgpt_plan_type
: undefined,
typeof chatgpt.chatgpt_plan_type === "string" ? chatgpt.chatgpt_plan_type : undefined,
};
}
/**
* Decode a JWT's `exp` claim (seconds since epoch, per RFC 7519) without
* verifying the signature. Returns `null` when the token isn't a decodable
* JWT or carries no numeric `exp`.
*
* Exported so sibling Codex import paths (e.g. the session-JSON normalizer
* at `codexSessionImport.ts`, #6636) can check expiry without duplicating a
* 3rd inline JWT decoder.
*/
export function decodeJwtExp(jwt: unknown): number | null {
const payload = decodeJwtPayload(jwt);
const exp = payload && typeof payload.exp === "number" ? payload.exp : null;
return exp !== null && Number.isFinite(exp) ? exp : null;
}
function pickString(...candidates: (string | undefined)[]): string | undefined {
for (const c of candidates) {
if (typeof c === "string" && c.trim()) return c.trim();
@@ -146,9 +153,7 @@ function unwrapCodexAuthJson(rec: Record<string, unknown>): Record<string, unkno
* its camelCase alias when it is absent, so a snake_case or mixed export keeps
* working unchanged.
*/
function applyCamelCaseAliases(
rec: Record<string, unknown>,
): Record<string, unknown> {
function applyCamelCaseAliases(rec: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...rec };
const fillFrom = (snake: string, value: unknown) => {
if (out[snake] === undefined && typeof value === "string" && value) {
@@ -181,9 +186,7 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
return { ok: false, error: "Record is not an object" };
}
const rec = applyCamelCaseAliases(
unwrapCodexAuthJson(input as Record<string, unknown>),
);
const rec = applyCamelCaseAliases(unwrapCodexAuthJson(input as Record<string, unknown>));
// Allow type field to be missing or "codex"; reject anything else explicitly so
// users don't accidentally import claude/gemini exports through this path.
@@ -211,15 +214,14 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
const chatgptAccountId = pickString(
fromJwt.chatgptAccountId,
rec.account_id as string | undefined,
rec.account_id as string | undefined
);
const chatgptPlanType = pickString(
fromJwt.chatgptPlanType,
rec.chatgpt_plan_type as string | undefined,
rec.chatgpt_plan_type as string | undefined
);
const expiresAt =
parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);
const expiresAt = parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);
const providerSpecificData: CodexImportPayload["providerSpecificData"] = {};
if (chatgptAccountId) providerSpecificData.chatgptAccountId = chatgptAccountId;

View File

@@ -0,0 +1,125 @@
/**
* Codex (OpenAI) session-JSON normalizer
*
* Accepts the raw JSON object copied straight from
* `https://chatgpt.com/api/auth/session` (`{ user: {...}, accessToken, expires }`,
* NextAuth's session-endpoint shape) and extracts a bare access token + optional
* email, the same credential type already accepted by the bare-token import path
* (`POST /api/oauth/codex/import-token`, #1290) — this module only widens what
* shapes of pasted input can reach that endpoint.
*
* Pure: no I/O, no network, no DB import. Safe to unit-test. Mirrors the style
* of `codexAuthImport.ts` / `codexImport.ts`, deliberately kept separate from
* both: the bulk `auth.json` import requires a `refresh_token`; this path is
* for a bare access token and deliberately does not.
*
* Ref: #6636
*/
import { decodeJwtExp } from "@/lib/oauth/services/codexImport";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ParsedCodexSession = { accessToken: string; email?: string };
export type ParseResult = { ok: true; session: ParsedCodexSession } | { ok: false; error: string };
// ── Internal helpers ──────────────────────────────────────────────────────────
function toRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Looks like a JWT (3 dot-separated segments) — a loose shape check only. */
function looksLikeJwt(value: string): boolean {
return value.split(".").length === 3;
}
/**
* Field-name aliases for the access token across known Codex/ChatGPT session
* export shapes: the literal `accessToken` returned by `chatgpt.com/api/auth/session`
* first, then snake_case and alternate names, then the same aliases nested one
* level under `tokens` (mirrors `unwrapCodexAuthJson` in `codexAuthImport.ts`).
*/
function findAccessToken(rec: Record<string, unknown>): string | undefined {
const direct =
toNonEmptyString(rec.accessToken) ||
toNonEmptyString(rec.access_token) ||
toNonEmptyString(rec.sessionToken) ||
toNonEmptyString(rec.session_token);
if (direct) return direct;
const nested = toRecord(rec.tokens);
if (!nested) return undefined;
return toNonEmptyString(nested.access_token) || toNonEmptyString(nested.accessToken);
}
/** True when either expiry signal (top-level `expires` ISO string, or JWT `exp`) is in the past. */
function isExpired(rec: Record<string, unknown>, accessToken: string): boolean {
const expiresField = toNonEmptyString(rec.expires);
if (expiresField) {
const ms = Date.parse(expiresField);
if (Number.isFinite(ms) && ms <= Date.now()) return true;
}
const exp = decodeJwtExp(accessToken);
return exp !== null && exp * 1000 <= Date.now();
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* True if the pasted text looks like a JSON object (vs a bare JWT or an OAuth
* callback URL/code) — a cheap guard so the modal only attempts the JSON
* normalizer on plausible input and every other paste shape falls through to
* the existing parsers unchanged.
*/
export function looksLikeCodexSessionJson(value: string): boolean {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed.startsWith("{")) return false;
try {
return toRecord(JSON.parse(trimmed)) !== null;
} catch {
return false;
}
}
/**
* Extract a bare access token (+ optional email) from a parsed
* `chatgpt.com/api/auth/session`-shaped JSON value. Returns a typed error
* (never throws) for malformed, tokenless, or expired input.
*/
export function parseCodexSessionJson(raw: unknown): ParseResult {
const rec = toRecord(raw);
if (!rec) {
return { ok: false, error: "Pasted session data is not a JSON object" };
}
const accessToken = findAccessToken(rec);
if (!accessToken) {
return {
ok: false,
error:
"Could not find an access token field in the pasted session JSON (expected accessToken, access_token, sessionToken, or tokens.access_token)",
};
}
if (!looksLikeJwt(accessToken)) {
return { ok: false, error: "The access token field does not look like a valid JWT" };
}
if (isExpired(rec, accessToken)) {
return {
ok: false,
error: "Session is expired — sign in to chatgpt.com again and re-copy the session JSON",
};
}
const user = toRecord(rec.user);
const email = (user && toNonEmptyString(user.email)) || undefined;
return { ok: true, session: { accessToken, email } };
}

View File

@@ -9,6 +9,10 @@ import LinkifiedText from "./LinkifiedText";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { parseResponseBody, getErrorMessage } from "@/shared/utils/api";
import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit";
import {
looksLikeCodexSessionJson,
parseCodexSessionJson,
} from "@/lib/oauth/utils/codexSessionImport";
const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]);
@@ -23,6 +27,27 @@ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]);
*/
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
// POST a bare Codex access token to the access-token-only import endpoint
// (#1290); shared by the bare-JWT and session-JSON paste branches (#6636).
async function submitCodexAccessToken(
accessToken: string,
name: string | undefined,
setStep: (s: string) => void,
onSuccess?: () => void
): Promise<void> {
const res = await fetch("/api/oauth/codex/import-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, name }),
});
const data = (await parseResponseBody(res)) as Record<string, unknown>;
if (!res.ok) {
throw new Error(getErrorMessage(data, res.status, "Failed to import access token"));
}
setStep("success");
onSuccess?.();
}
type OAuthModalProps = {
isOpen: boolean;
provider?: string;
@@ -672,17 +697,24 @@ export default function OAuthModal({
// raw-token paste pattern. Routed through the access-token-only import
// endpoint (#1290) instead of the authorization-code exchange below.
if (provider === "codex" && /^eyJ/.test(callbackUrl.trim())) {
const res = await fetch("/api/oauth/codex/import-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken: callbackUrl.trim() }),
});
const data = (await parseResponseBody(res)) as Record<string, unknown>;
if (!res.ok) {
throw new Error(getErrorMessage(data, res.status, "Failed to import access token"));
await submitCodexAccessToken(callbackUrl.trim(), undefined, setStep, onSuccess);
return;
}
// Codex: full session JSON from chatgpt.com/api/auth/session
// (`{user, accessToken, expires}`), not just the bare token (#6636).
if (provider === "codex" && looksLikeCodexSessionJson(callbackUrl)) {
const result = parseCodexSessionJson(JSON.parse(callbackUrl.trim()));
if (!result.ok) {
setError(result.error);
return;
}
setStep("success");
onSuccess?.();
await submitCodexAccessToken(
result.session.accessToken,
result.session.email,
setStep,
onSuccess
);
return;
}

View File

@@ -141,3 +141,42 @@ test("import-token: error responses never leak a stack trace", async () => {
assert.ok(!JSON.stringify(body).includes("at /"), "must not leak a stack trace");
assert.ok(!JSON.stringify(body).includes(".ts:"), "must not leak a source location");
});
// #6636 — defense-in-depth: the route also accepts the full session JSON
// object copied from chatgpt.com/api/auth/session under `{ session: {...} }`.
test("import-token: accepts a full session-JSON body and creates a connection identical to the bare-accessToken path", async () => {
const accessToken = makeJwt({
email: "session-json@example.com",
"https://api.openai.com/auth": { chatgpt_account_id: "acct-session" },
});
const { status, body } = await postImportToken({
session: {
user: { email: "session-json@example.com" },
accessToken,
expires: new Date(Date.now() + 60_000).toISOString(),
},
});
assert.equal(status, 200);
assert.equal(body.success, true);
assert.equal(body.connection.provider, "codex");
assert.equal(body.connection.email, "session-json@example.com");
const rows = await providersDb.getProviderConnections({ provider: "codex" });
const created = rows.find((r) => r.id === body.connection.id);
assert.equal(created?.authType, "access_token");
assert.equal(created?.accessToken, accessToken);
});
test("import-token: session-JSON body with an expired session is rejected with an actionable 400", async () => {
const accessToken = makeJwt({ email: "expired@example.com" });
const { status, body } = await postImportToken({
session: {
accessToken,
expires: new Date(Date.now() - 60_000).toISOString(),
},
});
assert.equal(status, 400);
assert.match(body.error.message, /expired/i);
});

View File

@@ -0,0 +1,118 @@
// Unit tests for the Codex session-JSON normalizer (#6636).
//
// Reproduces the bug: pasting the full JSON object copied from
// `https://chatgpt.com/api/auth/session` (`{user, accessToken, expires}`)
// into the Codex OAuth modal used to fall through to the OAuth-code parser
// and error out — only a bare JWT (`/^eyJ/`) was recognized. These tests
// exercise the pure normalizer in isolation (no DB, no fetch).
import test from "node:test";
import assert from "node:assert/strict";
import {
looksLikeCodexSessionJson,
parseCodexSessionJson,
} from "../../src/lib/oauth/utils/codexSessionImport.ts";
function b64url(obj: unknown): string {
return Buffer.from(JSON.stringify(obj))
.toString("base64")
.replace(/=+$/, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function makeJwt(payload: Record<string, unknown>): string {
const header = b64url({ alg: "RS256", typ: "JWT" });
const body = b64url(payload);
return `${header}.${body}.signature`;
}
test("parseCodexSessionJson: extracts accessToken from the exact chatgpt.com/api/auth/session shape", () => {
const accessToken = makeJwt({ email: "session@example.com" });
const result = parseCodexSessionJson({
user: { email: "session@example.com" },
accessToken,
expires: new Date(Date.now() + 60_000).toISOString(),
});
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.session.accessToken, accessToken);
assert.equal(result.session.email, "session@example.com");
}
});
test("parseCodexSessionJson: accepts access_token, sessionToken, and nested tokens.access_token aliases", () => {
const jwt = makeJwt({});
const snakeCase = parseCodexSessionJson({ access_token: jwt });
assert.equal(snakeCase.ok, true);
const sessionToken = parseCodexSessionJson({ sessionToken: jwt });
assert.equal(sessionToken.ok, true);
const nested = parseCodexSessionJson({ tokens: { access_token: jwt } });
assert.equal(nested.ok, true);
if (nested.ok) assert.equal(nested.session.accessToken, jwt);
});
test("parseCodexSessionJson: rejects malformed / non-object input with a typed error, not a throw", () => {
assert.equal(parseCodexSessionJson(null).ok, false);
assert.equal(parseCodexSessionJson("just a string").ok, false);
assert.equal(parseCodexSessionJson(42).ok, false);
assert.equal(parseCodexSessionJson([]).ok, false);
const result = parseCodexSessionJson("just a string");
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /not a JSON object/i);
});
test("parseCodexSessionJson: rejects an object with no recognizable token field", () => {
const result = parseCodexSessionJson({ user: { email: "no-token@example.com" } });
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /access token/i);
});
test("parseCodexSessionJson: rejects an expired session via the top-level `expires` field", () => {
const accessToken = makeJwt({});
const result = parseCodexSessionJson({
accessToken,
expires: new Date(Date.now() - 60_000).toISOString(),
});
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /expired/i);
});
test("parseCodexSessionJson: rejects an expired session via the JWT `exp` claim", () => {
const pastExpSeconds = Math.floor((Date.now() - 60_000) / 1000);
const accessToken = makeJwt({ exp: pastExpSeconds });
const result = parseCodexSessionJson({ accessToken });
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /expired/i);
});
test("parseCodexSessionJson: accepts a non-expired session with a future JWT `exp` claim", () => {
const futureExpSeconds = Math.floor((Date.now() + 60_000) / 1000);
const accessToken = makeJwt({ exp: futureExpSeconds });
const result = parseCodexSessionJson({ accessToken });
assert.equal(result.ok, true);
});
test("parseCodexSessionJson: rejects a token field that does not look like a JWT", () => {
const result = parseCodexSessionJson({ accessToken: "not-a-jwt" });
assert.equal(result.ok, false);
if (!result.ok) assert.match(result.error, /JWT/i);
});
test("looksLikeCodexSessionJson: true for a JSON object string", () => {
assert.equal(looksLikeCodexSessionJson('{"accessToken":"eyJ.eyJ.sig"}'), true);
assert.equal(looksLikeCodexSessionJson(' {"user":{}} '), true);
});
test("looksLikeCodexSessionJson: false for a bare JWT, an OAuth callback URL, and malformed JSON", () => {
assert.equal(looksLikeCodexSessionJson("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.sig"), false);
assert.equal(
looksLikeCodexSessionJson("https://example.com/callback?code=abc&state=xyz"),
false
);
assert.equal(looksLikeCodexSessionJson("{not valid json"), false);
assert.equal(looksLikeCodexSessionJson(""), false);
});