codex-usage-analyzer
Advanced tools
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "title": "Codex Usage Analyzer Experimental Full Profile Contract v2", | ||
| "description": "Experimental opt-in profile output with selected custom pet data. This is separate from the stable Account Usage Contract.", | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "fullProfileContractVersion", | ||
| "kind", | ||
| "stability", | ||
| "status", | ||
| "usage", | ||
| "profile", | ||
| "activityInsights", | ||
| "pet" | ||
| ], | ||
| "properties": { | ||
| "fullProfileContractVersion": { | ||
| "const": 2 | ||
| }, | ||
| "kind": { | ||
| "const": "codex-usage-analyzer.fullProfile" | ||
| }, | ||
| "stability": { | ||
| "const": "experimental" | ||
| }, | ||
| "status": { | ||
| "enum": [ | ||
| "ok", | ||
| "partial", | ||
| "unavailable" | ||
| ] | ||
| }, | ||
| "usage": { | ||
| "$ref": "#/definitions/accountUsage" | ||
| }, | ||
| "profile": { | ||
| "oneOf": [ | ||
| { | ||
| "$ref": "#/definitions/profile" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "activityInsights": { | ||
| "oneOf": [ | ||
| { | ||
| "$ref": "#/definitions/activityInsights" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "pet": { | ||
| "$ref": "#/definitions/pet" | ||
| } | ||
| }, | ||
| "definitions": { | ||
| "nullableNonNegativeInteger": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "maximum": 9007199254740991 | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "nullablePercentage": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "maximum": 100 | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "nullableString64": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 64, | ||
| "pattern": "^[^\\u0000-\\u001F\\u007F]*$" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "accountUsage": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "contractVersion", | ||
| "capturedAt", | ||
| "summary", | ||
| "dailyUsageBuckets" | ||
| ], | ||
| "properties": { | ||
| "contractVersion": { | ||
| "const": 1 | ||
| }, | ||
| "capturedAt": { | ||
| "type": "string", | ||
| "format": "date-time" | ||
| }, | ||
| "summary": { | ||
| "$ref": "#/definitions/accountUsageSummary" | ||
| }, | ||
| "dailyUsageBuckets": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "array", | ||
| "items": { | ||
| "$ref": "#/definitions/accountUsageDailyBucket" | ||
| } | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| "accountUsageSummary": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "lifetimeTokens", | ||
| "peakDailyTokens", | ||
| "longestRunningTurnSec", | ||
| "currentStreakDays", | ||
| "longestStreakDays" | ||
| ], | ||
| "properties": { | ||
| "lifetimeTokens": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "peakDailyTokens": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "longestRunningTurnSec": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "currentStreakDays": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "longestStreakDays": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| } | ||
| } | ||
| }, | ||
| "accountUsageDailyBucket": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "startDate", | ||
| "tokens" | ||
| ], | ||
| "properties": { | ||
| "startDate": { | ||
| "type": "string", | ||
| "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" | ||
| }, | ||
| "tokens": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "maximum": 9007199254740991 | ||
| } | ||
| } | ||
| }, | ||
| "profile": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "displayName", | ||
| "username", | ||
| "avatarUrl", | ||
| "planType" | ||
| ], | ||
| "properties": { | ||
| "displayName": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 256, | ||
| "pattern": "^[^\\u0000-\\u001F\\u007F]*$" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "username": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 100, | ||
| "pattern": "^[^\\u0000-\\u001F\\u007F]*$" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "avatarUrl": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "string", | ||
| "format": "uri", | ||
| "pattern": "^https://", | ||
| "maxLength": 2048 | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "planType": { | ||
| "$ref": "#/definitions/nullableString64" | ||
| } | ||
| } | ||
| }, | ||
| "activityInsights": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "fastModePercent", | ||
| "reasoningEffort", | ||
| "reasoningEffortPercent", | ||
| "skillsExplored", | ||
| "totalSkillsUsed", | ||
| "totalThreads", | ||
| "topInvocations" | ||
| ], | ||
| "properties": { | ||
| "fastModePercent": { | ||
| "$ref": "#/definitions/nullablePercentage" | ||
| }, | ||
| "reasoningEffort": { | ||
| "$ref": "#/definitions/nullableString64" | ||
| }, | ||
| "reasoningEffortPercent": { | ||
| "$ref": "#/definitions/nullablePercentage" | ||
| }, | ||
| "skillsExplored": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "totalSkillsUsed": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "totalThreads": { | ||
| "$ref": "#/definitions/nullableNonNegativeInteger" | ||
| }, | ||
| "topInvocations": { | ||
| "oneOf": [ | ||
| { | ||
| "type": "array", | ||
| "maxItems": 100, | ||
| "items": { | ||
| "$ref": "#/definitions/topInvocation" | ||
| } | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| }, | ||
| "topInvocation": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "type", | ||
| "name", | ||
| "usageCount" | ||
| ], | ||
| "properties": { | ||
| "type": { | ||
| "enum": [ | ||
| "plugin", | ||
| "skill" | ||
| ] | ||
| }, | ||
| "name": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "maxLength": 256, | ||
| "pattern": "^[^\\u0000-\\u001F\\u007F]*$" | ||
| }, | ||
| "usageCount": { | ||
| "type": "integer", | ||
| "minimum": 0, | ||
| "maximum": 9007199254740991 | ||
| } | ||
| } | ||
| }, | ||
| "pet": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "status", | ||
| "reason", | ||
| "kind", | ||
| "image" | ||
| ], | ||
| "properties": { | ||
| "status": { | ||
| "enum": [ | ||
| "ok", | ||
| "unavailable" | ||
| ] | ||
| }, | ||
| "reason": { | ||
| "oneOf": [ | ||
| { | ||
| "enum": [ | ||
| "selected_pet_state_unavailable", | ||
| "selected_pet_not_custom", | ||
| "selected_pet_selection_unavailable", | ||
| "selected_pet_manifest_unavailable", | ||
| "selected_pet_image_unavailable", | ||
| "selected_pet_image_invalid", | ||
| "selected_pet_image_too_large" | ||
| ] | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "kind": { | ||
| "oneOf": [ | ||
| { | ||
| "const": "custom" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| }, | ||
| "image": { | ||
| "oneOf": [ | ||
| { | ||
| "$ref": "#/definitions/petImage" | ||
| }, | ||
| { | ||
| "type": "null" | ||
| } | ||
| ] | ||
| } | ||
| }, | ||
| "allOf": [ | ||
| { | ||
| "if": { | ||
| "properties": { | ||
| "status": { | ||
| "const": "ok" | ||
| } | ||
| } | ||
| }, | ||
| "then": { | ||
| "properties": { | ||
| "reason": { | ||
| "type": "null" | ||
| }, | ||
| "kind": { | ||
| "const": "custom" | ||
| }, | ||
| "image": { | ||
| "$ref": "#/definitions/petImage" | ||
| } | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| "if": { | ||
| "properties": { | ||
| "status": { | ||
| "const": "unavailable" | ||
| } | ||
| } | ||
| }, | ||
| "then": { | ||
| "properties": { | ||
| "reason": { | ||
| "enum": [ | ||
| "selected_pet_state_unavailable", | ||
| "selected_pet_not_custom", | ||
| "selected_pet_selection_unavailable", | ||
| "selected_pet_manifest_unavailable", | ||
| "selected_pet_image_unavailable", | ||
| "selected_pet_image_invalid", | ||
| "selected_pet_image_too_large" | ||
| ] | ||
| }, | ||
| "kind": { | ||
| "type": "null" | ||
| }, | ||
| "image": { | ||
| "type": "null" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| }, | ||
| "petImage": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "required": [ | ||
| "role", | ||
| "contentType", | ||
| "width", | ||
| "height", | ||
| "byteLength", | ||
| "sha256", | ||
| "base64" | ||
| ], | ||
| "properties": { | ||
| "role": { | ||
| "const": "spritesheet" | ||
| }, | ||
| "contentType": { | ||
| "enum": [ | ||
| "image/webp", | ||
| "image/png" | ||
| ] | ||
| }, | ||
| "width": { | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "maximum": 8192 | ||
| }, | ||
| "height": { | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "maximum": 8192 | ||
| }, | ||
| "byteLength": { | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "maximum": 8388608 | ||
| }, | ||
| "sha256": { | ||
| "type": "string", | ||
| "pattern": "^[0-9a-f]{64}$" | ||
| }, | ||
| "base64": { | ||
| "type": "string", | ||
| "minLength": 4, | ||
| "maxLength": 11184812, | ||
| "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
| import { emitKeypressEvents } from "node:readline"; | ||
| const MAX_SELECTOR_DISPLAY_NAME_LENGTH = 128; | ||
| export async function selectExperimentalPet(catalog, options = {}) { | ||
| const input = options.input ?? process.stdin; | ||
| const output = options.output ?? process.stderr; | ||
| const items = normalizeCatalog(catalog); | ||
| if (items.length === 0 | ||
| || input?.isTTY !== true | ||
| || output?.isTTY !== true | ||
| || typeof input.setRawMode !== "function" | ||
| || typeof input.on !== "function" | ||
| || typeof input.off !== "function") { | ||
| return null; | ||
| } | ||
| const initialIndex = items.findIndex((item) => item.selected); | ||
| let cursor = initialIndex < 0 ? 0 : initialIndex; | ||
| return new Promise((resolve) => { | ||
| const previousRawMode = input.isRaw === true; | ||
| const wasFlowing = input.readableFlowing === true; | ||
| let active = true; | ||
| let rendered = false; | ||
| function cleanup() { | ||
| if (!active) return; | ||
| active = false; | ||
| input.off("keypress", onKeypress); | ||
| try { | ||
| input.setRawMode(previousRawMode); | ||
| } catch { | ||
| // Terminal cleanup is best effort after selection has settled. | ||
| } | ||
| if (!wasFlowing && typeof input.pause === "function") input.pause(); | ||
| try { | ||
| output.write("\x1b[?25h"); | ||
| } catch { | ||
| // Output cleanup is best effort after selection has settled. | ||
| } | ||
| } | ||
| function settle(value) { | ||
| cleanup(); | ||
| try { | ||
| output.write("\n"); | ||
| } catch { | ||
| // The caller receives the selection even if the final newline fails. | ||
| } | ||
| resolve(value); | ||
| } | ||
| function render() { | ||
| const lineCount = items.length + 2; | ||
| if (rendered) output.write(`\x1b[${lineCount}A`); | ||
| output.write("\r\x1b[2KSelect a custom Codex pet\n"); | ||
| for (let index = 0; index < items.length; index += 1) { | ||
| const marker = index === cursor ? ">" : " "; | ||
| output.write( | ||
| `\r\x1b[2K${marker} [${items[index].key}] ${items[index].label}\n` | ||
| ); | ||
| } | ||
| output.write("\r\x1b[2KUse Up/Down and Enter; Esc cancels\n"); | ||
| rendered = true; | ||
| } | ||
| function onKeypress(_text, key = {}) { | ||
| if (!active) return; | ||
| if (key.name === "up") { | ||
| cursor = (cursor - 1 + items.length) % items.length; | ||
| } else if (key.name === "down") { | ||
| cursor = (cursor + 1) % items.length; | ||
| } else if (key.name === "return" || key.name === "enter") { | ||
| settle(items[cursor].key); | ||
| return; | ||
| } else if (key.name === "escape" || (key.ctrl && key.name === "c")) { | ||
| settle(null); | ||
| return; | ||
| } else { | ||
| return; | ||
| } | ||
| try { | ||
| render(); | ||
| } catch { | ||
| settle(null); | ||
| } | ||
| } | ||
| try { | ||
| input.setRawMode(true); | ||
| emitKeypressEvents(input); | ||
| input.on("keypress", onKeypress); | ||
| if (typeof input.resume === "function") input.resume(); | ||
| output.write("\x1b[?25l"); | ||
| render(); | ||
| } catch { | ||
| settle(null); | ||
| } | ||
| }); | ||
| } | ||
| function normalizeCatalog(value) { | ||
| if (!Array.isArray(value)) return []; | ||
| return value.flatMap((item) => { | ||
| if (!isRecord(item) | ||
| || !Number.isSafeInteger(item.key) | ||
| || item.key < 1 | ||
| || typeof item.selected !== "boolean") { | ||
| return []; | ||
| } | ||
| return [{ | ||
| key: item.key, | ||
| selected: item.selected, | ||
| label: normalizeDisplayName(item.displayName) ?? `Custom pet ${item.key}` | ||
| }]; | ||
| }); | ||
| } | ||
| function normalizeDisplayName(value) { | ||
| if (typeof value !== "string") return null; | ||
| const trimmed = value.trim(); | ||
| if (trimmed.length < 1 | ||
| || trimmed.length > MAX_SELECTOR_DISPLAY_NAME_LENGTH | ||
| || /[\u0000-\u001f\u007f]/u.test(trimmed)) { | ||
| return null; | ||
| } | ||
| return trimmed; | ||
| } | ||
| function isRecord(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } |
| import { createHash } from "node:crypto"; | ||
| import { | ||
| lstat, | ||
| open, | ||
| readdir, | ||
| realpath | ||
| } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { | ||
| extname, | ||
| isAbsolute, | ||
| join, | ||
| normalize, | ||
| relative, | ||
| resolve, | ||
| sep | ||
| } from "node:path"; | ||
| export const EXPERIMENTAL_PET_STATUSES = Object.freeze([ | ||
| "ok", | ||
| "unavailable" | ||
| ]); | ||
| export const EXPERIMENTAL_PET_REASONS = Object.freeze([ | ||
| "selected_pet_state_unavailable", | ||
| "selected_pet_not_custom", | ||
| "selected_pet_selection_unavailable", | ||
| "selected_pet_manifest_unavailable", | ||
| "selected_pet_image_unavailable", | ||
| "selected_pet_image_invalid", | ||
| "selected_pet_image_too_large" | ||
| ]); | ||
| export const EXPERIMENTAL_PET_IMAGE_CONTENT_TYPES = Object.freeze([ | ||
| "image/webp", | ||
| "image/png" | ||
| ]); | ||
| export const EXPERIMENTAL_PET_CATALOG_FIELDS = Object.freeze([ | ||
| "key", | ||
| "displayName", | ||
| "selected" | ||
| ]); | ||
| export const MAX_EXPERIMENTAL_PET_STATE_BYTES = 1_048_576; | ||
| export const MAX_EXPERIMENTAL_PET_MANIFEST_BYTES = 65_536; | ||
| export const MAX_EXPERIMENTAL_PET_IMAGE_BYTES = 8_388_608; | ||
| export const MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION = 8_192; | ||
| export const MAX_EXPERIMENTAL_PET_IMAGE_PIXELS = 16_777_216; | ||
| const STATE_FILE_NAME = ".codex-global-state.json"; | ||
| const PERSISTED_ATOM_KEY = "electron-persisted-atom-state"; | ||
| const SELECTED_AVATAR_KEY = "selected-avatar-id"; | ||
| const CUSTOM_AVATAR_PREFIX = "custom:"; | ||
| const PETS_DIRECTORY_NAME = "pets"; | ||
| const PET_MANIFEST_NAME = "pet.json"; | ||
| const MAX_SELECTED_ID_LENGTH = 256; | ||
| const MAX_DISPLAY_NAME_LENGTH = 128; | ||
| const MAX_SPRITESHEET_PATH_LENGTH = 1_024; | ||
| const PROTOTYPE_SENSITIVE_KEYS = Object.freeze([ | ||
| "__proto__", | ||
| "constructor", | ||
| "prototype" | ||
| ]); | ||
| const CONTENT_TYPE_BY_EXTENSION = Object.freeze({ | ||
| ".png": "image/png", | ||
| ".webp": "image/webp" | ||
| }); | ||
| export async function readExperimentalPet(options = {}) { | ||
| const codexHome = resolveCodexHome(options); | ||
| let manifestResult; | ||
| if (Object.hasOwn(options, "petKey")) { | ||
| if (!isPositiveSafeInteger(options.petKey)) { | ||
| return unavailablePet("selected_pet_selection_unavailable"); | ||
| } | ||
| const catalog = await readPetCatalog(codexHome, null); | ||
| const selectedEntry = catalog.find((entry) => entry.key === options.petKey); | ||
| if (selectedEntry === undefined) { | ||
| return unavailablePet("selected_pet_selection_unavailable"); | ||
| } | ||
| manifestResult = selectedEntry.manifest; | ||
| } else { | ||
| const selected = await readSelectedCustomPet(codexHome); | ||
| if (selected.reason !== null) { | ||
| return unavailablePet(selected.reason); | ||
| } | ||
| manifestResult = await readSelectedManifest( | ||
| codexHome, | ||
| selected.customId | ||
| ); | ||
| selected.customId = null; | ||
| if (manifestResult.reason !== null) { | ||
| return unavailablePet(manifestResult.reason); | ||
| } | ||
| } | ||
| const imageResult = await readPetImage(manifestResult); | ||
| manifestResult.petDirectory = null; | ||
| manifestResult.spritesheetPath = null; | ||
| if (imageResult.reason !== null) { | ||
| return unavailablePet(imageResult.reason); | ||
| } | ||
| return { | ||
| status: "ok", | ||
| reason: null, | ||
| kind: "custom", | ||
| image: imageResult.image | ||
| }; | ||
| } | ||
| export async function listExperimentalPets(options = {}) { | ||
| const codexHome = resolveCodexHome(options); | ||
| const selected = await readSelectedCustomPet(codexHome); | ||
| const selectedCustomId = selected.reason === null ? selected.customId : null; | ||
| const catalog = await readPetCatalog(codexHome, selectedCustomId); | ||
| selected.customId = null; | ||
| return catalog.map((entry) => ({ | ||
| key: entry.key, | ||
| displayName: entry.displayName, | ||
| selected: entry.selected | ||
| })); | ||
| } | ||
| function resolveCodexHome(options) { | ||
| if (typeof options.codexHome === "string" | ||
| && options.codexHome.trim().length > 0) { | ||
| return resolve(options.codexHome); | ||
| } | ||
| const env = isRecord(options.env) ? options.env : process.env; | ||
| if (typeof env.CODEX_HOME === "string" | ||
| && env.CODEX_HOME.trim().length > 0) { | ||
| return resolve(env.CODEX_HOME); | ||
| } | ||
| const homeDir = typeof options.homeDir === "string" | ||
| && options.homeDir.trim().length > 0 | ||
| ? options.homeDir | ||
| : homedir(); | ||
| return resolve(homeDir, ".codex"); | ||
| } | ||
| async function readSelectedCustomPet(codexHome) { | ||
| const result = await readLimitedJson( | ||
| join(codexHome, STATE_FILE_NAME), | ||
| MAX_EXPERIMENTAL_PET_STATE_BYTES | ||
| ); | ||
| if (!result.ok || !isRecord(result.value)) { | ||
| return selectedFailure("selected_pet_state_unavailable"); | ||
| } | ||
| const persistedState = result.value[PERSISTED_ATOM_KEY]; | ||
| if (!isRecord(persistedState) | ||
| || hasPrototypeSensitiveKey(persistedState)) { | ||
| return selectedFailure("selected_pet_state_unavailable"); | ||
| } | ||
| const selectedAvatarId = persistedState[SELECTED_AVATAR_KEY]; | ||
| if (typeof selectedAvatarId !== "string" | ||
| || selectedAvatarId.length < 1 | ||
| || selectedAvatarId.length > MAX_SELECTED_ID_LENGTH | ||
| || selectedAvatarId.trim() !== selectedAvatarId | ||
| || hasControlCharacter(selectedAvatarId)) { | ||
| return selectedFailure("selected_pet_state_unavailable"); | ||
| } | ||
| if (!selectedAvatarId.startsWith(CUSTOM_AVATAR_PREFIX)) { | ||
| return selectedFailure("selected_pet_not_custom"); | ||
| } | ||
| const customId = selectedAvatarId.slice(CUSTOM_AVATAR_PREFIX.length); | ||
| if (customId.length < 1 || hasControlCharacter(customId)) { | ||
| return selectedFailure("selected_pet_state_unavailable"); | ||
| } | ||
| return { | ||
| reason: null, | ||
| customId | ||
| }; | ||
| } | ||
| async function readSelectedManifest(codexHome, customId) { | ||
| const petsDirectory = join(codexHome, PETS_DIRECTORY_NAME); | ||
| let entries; | ||
| try { | ||
| entries = await readdir(petsDirectory, { withFileTypes: true }); | ||
| } catch { | ||
| return manifestFailure(); | ||
| } | ||
| const selectedEntry = entries.find((entry) => ( | ||
| entry.isDirectory() && entry.name === customId | ||
| )); | ||
| if (selectedEntry === undefined) { | ||
| return manifestFailure(); | ||
| } | ||
| const petDirectory = join(petsDirectory, selectedEntry.name); | ||
| const manifest = await readCatalogManifest(petDirectory); | ||
| return manifest ?? manifestFailure(); | ||
| } | ||
| async function readPetCatalog(codexHome, selectedCustomId) { | ||
| const petsDirectory = join(codexHome, PETS_DIRECTORY_NAME); | ||
| let entries; | ||
| try { | ||
| entries = await readdir(petsDirectory, { withFileTypes: true }); | ||
| } catch { | ||
| return []; | ||
| } | ||
| const directories = entries | ||
| .filter((entry) => entry.isDirectory()) | ||
| .sort(compareDirectoryEntries); | ||
| const catalog = []; | ||
| for (const entry of directories) { | ||
| const manifest = await readCatalogManifest( | ||
| join(petsDirectory, entry.name) | ||
| ); | ||
| if (manifest === null) continue; | ||
| catalog.push({ | ||
| key: catalog.length + 1, | ||
| displayName: manifest.displayName, | ||
| selected: selectedCustomId !== null && entry.name === selectedCustomId, | ||
| manifest | ||
| }); | ||
| } | ||
| return catalog; | ||
| } | ||
| async function readCatalogManifest(petDirectory) { | ||
| const manifestResult = await readLimitedJson( | ||
| join(petDirectory, PET_MANIFEST_NAME), | ||
| MAX_EXPERIMENTAL_PET_MANIFEST_BYTES | ||
| ); | ||
| if (!manifestResult.ok | ||
| || !isRecord(manifestResult.value) | ||
| || hasPrototypeSensitiveKey(manifestResult.value)) { | ||
| return null; | ||
| } | ||
| const spritesheetPath = normalizeRelativeImagePath( | ||
| manifestResult.value.spritesheetPath | ||
| ); | ||
| if (spritesheetPath === null) { | ||
| return null; | ||
| } | ||
| return { | ||
| reason: null, | ||
| petDirectory, | ||
| spritesheetPath, | ||
| displayName: normalizeDisplayName(manifestResult.value.displayName) | ||
| }; | ||
| } | ||
| async function readPetImage(manifestResult) { | ||
| const extension = extname(manifestResult.spritesheetPath).toLowerCase(); | ||
| const contentType = CONTENT_TYPE_BY_EXTENSION[extension]; | ||
| if (contentType === undefined) { | ||
| return imageFailure("selected_pet_image_invalid"); | ||
| } | ||
| const imagePath = join( | ||
| manifestResult.petDirectory, | ||
| manifestResult.spritesheetPath | ||
| ); | ||
| const containedPath = await resolveContainedFile( | ||
| manifestResult.petDirectory, | ||
| imagePath | ||
| ); | ||
| if (containedPath === null) { | ||
| return imageFailure("selected_pet_image_unavailable"); | ||
| } | ||
| const readResult = await readBoundedFile( | ||
| containedPath, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_BYTES | ||
| ); | ||
| if (!readResult.ok) { | ||
| return imageFailure(readResult.tooLarge | ||
| ? "selected_pet_image_too_large" | ||
| : "selected_pet_image_unavailable"); | ||
| } | ||
| const dimensions = contentType === "image/png" | ||
| ? readPngDimensions(readResult.bytes) | ||
| : readWebpDimensions(readResult.bytes); | ||
| if (!isAllowedDimensions(dimensions)) { | ||
| return imageFailure("selected_pet_image_invalid"); | ||
| } | ||
| return { | ||
| reason: null, | ||
| image: { | ||
| role: "spritesheet", | ||
| contentType, | ||
| width: dimensions.width, | ||
| height: dimensions.height, | ||
| byteLength: readResult.bytes.byteLength, | ||
| sha256: createHash("sha256").update(readResult.bytes).digest("hex"), | ||
| base64: readResult.bytes.toString("base64") | ||
| } | ||
| }; | ||
| } | ||
| async function readLimitedJson(filePath, maximumBytes) { | ||
| const result = await readBoundedFile(filePath, maximumBytes); | ||
| if (!result.ok) return { ok: false, value: null }; | ||
| try { | ||
| const text = new TextDecoder("utf-8", { fatal: true }) | ||
| .decode(result.bytes); | ||
| return { ok: true, value: JSON.parse(text) }; | ||
| } catch { | ||
| return { ok: false, value: null }; | ||
| } | ||
| } | ||
| async function readBoundedFile(filePath, maximumBytes) { | ||
| let handle; | ||
| try { | ||
| const metadata = await lstat(filePath); | ||
| if (!metadata.isFile() || metadata.isSymbolicLink()) { | ||
| return { ok: false, tooLarge: false, bytes: null }; | ||
| } | ||
| if (!Number.isSafeInteger(metadata.size) || metadata.size > maximumBytes) { | ||
| return { ok: false, tooLarge: true, bytes: null }; | ||
| } | ||
| handle = await open(filePath, "r"); | ||
| const openedMetadata = await handle.stat(); | ||
| if (!openedMetadata.isFile() | ||
| || !Number.isSafeInteger(openedMetadata.size) | ||
| || openedMetadata.size !== metadata.size | ||
| || openedMetadata.size > maximumBytes) { | ||
| return { | ||
| ok: false, | ||
| tooLarge: openedMetadata.size > maximumBytes, | ||
| bytes: null | ||
| }; | ||
| } | ||
| const buffer = Buffer.alloc(openedMetadata.size + 1); | ||
| let offset = 0; | ||
| while (offset < buffer.byteLength) { | ||
| const { bytesRead } = await handle.read( | ||
| buffer, | ||
| offset, | ||
| buffer.byteLength - offset, | ||
| offset | ||
| ); | ||
| if (bytesRead === 0) break; | ||
| offset += bytesRead; | ||
| } | ||
| const finalMetadata = await handle.stat(); | ||
| if (offset !== openedMetadata.size | ||
| || finalMetadata.size !== openedMetadata.size) { | ||
| return { | ||
| ok: false, | ||
| tooLarge: offset > maximumBytes || finalMetadata.size > maximumBytes, | ||
| bytes: null | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| tooLarge: false, | ||
| bytes: buffer.subarray(0, offset) | ||
| }; | ||
| } catch { | ||
| return { ok: false, tooLarge: false, bytes: null }; | ||
| } finally { | ||
| await handle?.close().catch(() => {}); | ||
| } | ||
| } | ||
| async function resolveContainedFile(baseDirectory, candidatePath) { | ||
| try { | ||
| const candidateMetadata = await lstat(candidatePath); | ||
| if (!candidateMetadata.isFile() || candidateMetadata.isSymbolicLink()) { | ||
| return null; | ||
| } | ||
| const [baseRealPath, candidateRealPath] = await Promise.all([ | ||
| realpath(baseDirectory), | ||
| realpath(candidatePath) | ||
| ]); | ||
| const relativePath = relative(baseRealPath, candidateRealPath); | ||
| if (relativePath.length === 0 | ||
| || relativePath === ".." | ||
| || relativePath.startsWith(`..${sep}`) | ||
| || isAbsolute(relativePath)) { | ||
| return null; | ||
| } | ||
| return candidateRealPath; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function normalizeRelativeImagePath(value) { | ||
| if (typeof value !== "string" | ||
| || value.length < 1 | ||
| || value.length > MAX_SPRITESHEET_PATH_LENGTH | ||
| || value.trim() !== value | ||
| || hasControlCharacter(value) | ||
| || isAbsolute(value)) { | ||
| return null; | ||
| } | ||
| const normalizedPath = normalize(value); | ||
| if (normalizedPath === "." | ||
| || normalizedPath === ".." | ||
| || normalizedPath.startsWith(`..${sep}`)) { | ||
| return null; | ||
| } | ||
| return normalizedPath; | ||
| } | ||
| function normalizeDisplayName(value) { | ||
| if (typeof value !== "string") return null; | ||
| const trimmed = value.trim(); | ||
| if (trimmed.length < 1 | ||
| || trimmed.length > MAX_DISPLAY_NAME_LENGTH | ||
| || hasControlCharacter(trimmed)) { | ||
| return null; | ||
| } | ||
| return trimmed; | ||
| } | ||
| function compareDirectoryEntries(left, right) { | ||
| if (left.name < right.name) return -1; | ||
| if (left.name > right.name) return 1; | ||
| return 0; | ||
| } | ||
| function readPngDimensions(bytes) { | ||
| const signature = Buffer.from([ | ||
| 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a | ||
| ]); | ||
| if (bytes.byteLength < 33 | ||
| || !bytes.subarray(0, signature.byteLength).equals(signature) | ||
| || bytes.readUInt32BE(8) !== 13 | ||
| || bytes.toString("ascii", 12, 16) !== "IHDR") { | ||
| return null; | ||
| } | ||
| return { | ||
| width: bytes.readUInt32BE(16), | ||
| height: bytes.readUInt32BE(20) | ||
| }; | ||
| } | ||
| function readWebpDimensions(bytes) { | ||
| if (bytes.byteLength < 20 | ||
| || bytes.toString("ascii", 0, 4) !== "RIFF" | ||
| || bytes.toString("ascii", 8, 12) !== "WEBP" | ||
| || bytes.readUInt32LE(4) + 8 !== bytes.byteLength) { | ||
| return null; | ||
| } | ||
| let offset = 12; | ||
| while (offset + 8 <= bytes.byteLength) { | ||
| const chunkType = bytes.toString("ascii", offset, offset + 4); | ||
| const chunkLength = bytes.readUInt32LE(offset + 4); | ||
| const payloadOffset = offset + 8; | ||
| const payloadEnd = payloadOffset + chunkLength; | ||
| if (payloadEnd > bytes.byteLength) return null; | ||
| if (chunkType === "VP8X") { | ||
| if (chunkLength < 10) return null; | ||
| return { | ||
| width: 1 + readUInt24LE(bytes, payloadOffset + 4), | ||
| height: 1 + readUInt24LE(bytes, payloadOffset + 7) | ||
| }; | ||
| } | ||
| if (chunkType === "VP8L") { | ||
| if (chunkLength < 5 || bytes[payloadOffset] !== 0x2f) return null; | ||
| const bits = bytes.readUInt32LE(payloadOffset + 1); | ||
| return { | ||
| width: 1 + (bits & 0x3fff), | ||
| height: 1 + ((bits >>> 14) & 0x3fff) | ||
| }; | ||
| } | ||
| if (chunkType === "VP8 ") { | ||
| if (chunkLength < 10 | ||
| || bytes[payloadOffset + 3] !== 0x9d | ||
| || bytes[payloadOffset + 4] !== 0x01 | ||
| || bytes[payloadOffset + 5] !== 0x2a) { | ||
| return null; | ||
| } | ||
| return { | ||
| width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff, | ||
| height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff | ||
| }; | ||
| } | ||
| offset = payloadEnd + (chunkLength % 2); | ||
| } | ||
| return null; | ||
| } | ||
| function readUInt24LE(bytes, offset) { | ||
| return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16); | ||
| } | ||
| function isAllowedDimensions(value) { | ||
| return value !== null | ||
| && Number.isInteger(value.width) | ||
| && Number.isInteger(value.height) | ||
| && value.width >= 1 | ||
| && value.height >= 1 | ||
| && value.width <= MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION | ||
| && value.height <= MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION | ||
| && value.width * value.height <= MAX_EXPERIMENTAL_PET_IMAGE_PIXELS; | ||
| } | ||
| function hasPrototypeSensitiveKey(value) { | ||
| return PROTOTYPE_SENSITIVE_KEYS.some((key) => Object.hasOwn(value, key)); | ||
| } | ||
| function hasControlCharacter(value) { | ||
| return /[\u0000-\u001f\u007f]/u.test(value); | ||
| } | ||
| function isRecord(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
| function isPositiveSafeInteger(value) { | ||
| return Number.isSafeInteger(value) && value >= 1; | ||
| } | ||
| function selectedFailure(reason) { | ||
| return { reason, customId: null }; | ||
| } | ||
| function manifestFailure() { | ||
| return { | ||
| reason: "selected_pet_manifest_unavailable", | ||
| petDirectory: null, | ||
| spritesheetPath: null | ||
| }; | ||
| } | ||
| function imageFailure(reason) { | ||
| return { reason, image: null }; | ||
| } | ||
| function unavailablePet(reason) { | ||
| return { | ||
| status: "unavailable", | ||
| reason, | ||
| kind: null, | ||
| image: null | ||
| }; | ||
| } |
| import type { | ||
| ExperimentalFullProfileV1, | ||
| ExperimentalFullProfileV2, | ||
| ExperimentalPetCatalogItem | ||
| } from "./index.js"; | ||
| export type { | ||
| ExperimentalFullProfileActivityInsights, | ||
| ExperimentalFullProfileInvocation, | ||
| ExperimentalFullProfileProfile, | ||
| ExperimentalFullProfileStatus, | ||
| ExperimentalFullProfileV1, | ||
| ExperimentalFullProfileV2, | ||
| ExperimentalPet, | ||
| ExperimentalPetAvailable, | ||
| ExperimentalPetCatalogItem, | ||
| ExperimentalPetImage, | ||
| ExperimentalPetReason, | ||
| ExperimentalPetUnavailable | ||
| } from "./index.js"; | ||
| export interface ExperimentalPetSourceOptions { | ||
| codexHome?: string; | ||
| env?: Record<string, string | undefined>; | ||
| homeDir?: string; | ||
| } | ||
| export type ExperimentalPetSelector = ( | ||
| catalog: readonly ExperimentalPetCatalogItem[] | ||
| ) => number | null | Promise<number | null>; | ||
| export interface ExperimentalProfileV1Options { | ||
| timeoutMs?: number; | ||
| includePet?: false; | ||
| } | ||
| export interface ExperimentalProfileV2Options | ||
| extends ExperimentalPetSourceOptions { | ||
| timeoutMs?: number; | ||
| includePet: true; | ||
| petKey?: number; | ||
| selectPet?: ExperimentalPetSelector; | ||
| forcePetSelection?: boolean; | ||
| } | ||
| export type ExperimentalProfileOptions = | ||
| | ExperimentalProfileV1Options | ||
| | ExperimentalProfileV2Options; | ||
| export declare function listExperimentalPets( | ||
| options?: ExperimentalPetSourceOptions | ||
| ): Promise<ExperimentalPetCatalogItem[]>; | ||
| export declare function readExperimentalProfile( | ||
| options?: ExperimentalProfileV1Options | ||
| ): Promise<ExperimentalFullProfileV1>; | ||
| export declare function readExperimentalProfile( | ||
| options: ExperimentalProfileV2Options | ||
| ): Promise<ExperimentalFullProfileV2>; |
| export { listExperimentalPets } from "./experimental-pet.js"; | ||
| export { readExperimentalProfile } from "./experimental-profile-client.js"; |
@@ -5,3 +5,3 @@ # Downstream Integration Guide | ||
| The stable CLI and SDK own one artifact: the identity-free [Account Usage Contract](account-usage-contract.md). An explicit CLI-only experiment can emit a separate [Full Profile Envelope](experimental-full-profile.md). A downstream service owns identity, authorization, submission, persistence, rendering, cache behavior, privacy controls, and deletion. | ||
| The stable CLI and SDK own one artifact: the identity-free [Account Usage Contract](account-usage-contract.md). An explicit CLI or module experiment can emit a separate [Full Profile Envelope](experimental-full-profile.md). A downstream service owns identity, authorization, submission, persistence, rendering, cache behavior, privacy controls, and deletion. | ||
@@ -78,3 +78,3 @@ ## Recommended architecture | ||
| POST /v1/account-usage | ||
| Authorization: Bearer <downstream-issued-submit-token> | ||
| Authorization: bearer <downstream-issued-submit-token> | ||
| Content-Type: application/json | ||
@@ -183,2 +183,41 @@ ``` | ||
| ## Experimental custom pet | ||
| Full Profile v2 adds a required `pet` result when the caller explicitly uses | ||
| `--include-pet` or `readExperimentalProfile({ includePet: true })`. The default | ||
| Full Profile v1 path never reads pet files. A Tokenmon-style downstream can use | ||
| an available custom pet as a cosmetic portrait image, but it must not treat the | ||
| catalog key, display name, digest, or image as identity or ownership proof. | ||
| The default selection is the custom pet already selected in Codex Desktop. | ||
| Callers can explicitly choose a deterministic one-based catalog key, use an | ||
| interactive selector, or provide a module callback that returns a key. Keys are | ||
| valid only for the catalog snapshot in which they were listed. A downstream must | ||
| not persist them as stable pet ids or infer a first/only-pet fallback. | ||
| `pet.image.base64` contains the complete submitted spritesheet. Before accepting | ||
| or rendering it, a downstream should: | ||
| 1. Validate the complete Full Profile v2 document against | ||
| [`experimental-full-profile-v2.schema.json`](experimental-full-profile-v2.schema.json). | ||
| 2. Strictly decode base64 and reject non-canonical or trailing data. | ||
| 3. Match decoded bytes to `byteLength` and lowercase SHA-256. | ||
| 4. Enforce independent encoded-body, decoded-byte, dimension, and pixel limits. | ||
| 5. Verify the declared PNG or WebP content type from decoded bytes and fully | ||
| decode the image with a maintained image library. | ||
| 6. Safely re-encode and re-host the accepted image under a downstream-owned URL. | ||
| Never publish the submitted base64 directly, embed it unchanged in a public data | ||
| URL, or log the full envelope. Strip metadata and animation behavior the product | ||
| does not intentionally support. Treat image parsers as an attack surface and | ||
| perform decoding in a resource-bounded worker or sandbox where practical. | ||
| Pet retention and publication require a consent control separate from profile | ||
| identity and activity-insight consent. Explain that the local image bytes will be | ||
| uploaded, how they will be rendered, whether they will be public, and how long | ||
| source and derived assets remain. Replacing a pet should advance the profile | ||
| revision and invalidate derived assets. Revocation or deletion must remove the | ||
| source bytes, re-encoded assets, rendered cards, cached variants where possible, | ||
| and any content-addressed references that would keep the image reachable. | ||
| Handle envelope status explicitly: | ||
@@ -190,5 +229,5 @@ | ||
| | `partial` | Preserve nested official usage; render or store only non-null fields allowed by policy. | | ||
| | `unavailable` | Accept nested official usage only if the endpoint and product policy allow it; profile and activity are `null`. | | ||
| | `unavailable` | Accept nested official usage only if the endpoint and product policy allow it; v1 profile/activity are unavailable, while v2 may also report pet unavailability. | | ||
| Keep the experimental envelope authenticated and non-public by default. Validate it against [`experimental-full-profile.schema.json`](experimental-full-profile.schema.json), preserve null/empty semantics, and isolate stored records by `fullProfileContractVersion` rather than treating it as `contractVersion` 1. | ||
| Keep the experimental envelope authenticated and non-public by default. Validate v1 against [`experimental-full-profile.schema.json`](experimental-full-profile.schema.json) and v2 against [`experimental-full-profile-v2.schema.json`](experimental-full-profile-v2.schema.json), preserve null/empty semantics, and isolate stored records by `fullProfileContractVersion` rather than treating either envelope as `contractVersion` 1. | ||
@@ -195,0 +234,0 @@ ## Versioning |
| # Experimental Full Profile Contract | ||
| The Full Profile Envelope is an opt-in, CLI-only contract for identity and | ||
| The Full Profile Envelope is an opt-in CLI and module contract for identity and | ||
| activity fields shown by Codex. It combines one stable, identity-free Account | ||
| Usage Contract document with allowlisted fields from an unsupported private | ||
| profile endpoint. | ||
| profile endpoint. Full Profile v2 can additionally carry one explicitly opted-in | ||
| local custom Codex pet. | ||
| This contract is experimental. It is not part of the public JavaScript SDK and | ||
| This contract is experimental. It is isolated from the root JavaScript SDK and | ||
| does not change the default command or the stable | ||
| [`account/usage/read`](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) | ||
| path. The machine-readable source of truth is | ||
| [`experimental-full-profile.schema.json`](experimental-full-profile.schema.json). | ||
| path. The machine-readable sources of truth are the | ||
| [v1 schema](experimental-full-profile.schema.json) and | ||
| [v2 schema](experimental-full-profile-v2.schema.json). | ||
@@ -19,2 +21,5 @@ ## CLI | ||
| codex-usage-analyzer profile --json | ||
| codex-usage-analyzer profile --json --include-pet | ||
| codex-usage-analyzer profile --include-pet --pet-key 2 | ||
| codex-usage-analyzer profile --include-pet --select-pet | ||
| ``` | ||
@@ -29,2 +34,25 @@ | ||
| `--include-pet` is a second, independent opt-in and writes another warning: | ||
| ```text | ||
| codex-usage-analyzer: Warning: --include-pet reads local custom pet metadata and image bytes. | ||
| ``` | ||
| Pet options are order-independent. `--pet-key` accepts one positive safe integer; | ||
| `--pet-key` and `--select-pet` are mutually exclusive and valid only with | ||
| `--include-pet`. Invalid combinations fail before app-server, profile, catalog, | ||
| or pet-file access. | ||
| Selection follows this order: | ||
| 1. `--pet-key N` reads catalog key `N`. | ||
| 2. `--select-pet` forces an arrow-key selector and requires stdin and stderr TTYs. | ||
| 3. Otherwise, the custom pet selected in Codex Desktop is preferred. | ||
| 4. If that selection is unavailable, interactive human output offers the selector. | ||
| Default JSON output never prompts. Cancellation, an invalid key, an empty catalog, | ||
| or selection failure returns `selected_pet_selection_unavailable`; no first or | ||
| only catalog entry is selected implicitly. Catalog keys are deterministic and | ||
| one-based for a catalog snapshot, but are not persistent pet identifiers. | ||
| `profile --help` and `profile -h` do not start app-server or make a network | ||
@@ -34,3 +62,3 @@ request. There is no environment variable, configuration file, implicit mode, | ||
| ## Envelope | ||
| ## Full Profile v1 envelope | ||
@@ -87,3 +115,3 @@ ```json | ||
| ## Root fields | ||
| ## Full Profile v1 root fields | ||
@@ -100,2 +128,90 @@ | Field | Type | Meaning | | ||
| ## Full Profile v2 pet extension | ||
| When pet inclusion is requested, the envelope version is `2` and the required | ||
| root `pet` field is added. All v1 fields retain their meanings. | ||
| ```json | ||
| { | ||
| "fullProfileContractVersion": 2, | ||
| "kind": "codex-usage-analyzer.fullProfile", | ||
| "stability": "experimental", | ||
| "status": "partial", | ||
| "usage": { | ||
| "contractVersion": 1, | ||
| "capturedAt": "2026-01-01T00:00:00.000Z", | ||
| "summary": { | ||
| "lifetimeTokens": 1000, | ||
| "peakDailyTokens": 200, | ||
| "longestRunningTurnSec": 300, | ||
| "currentStreakDays": 4, | ||
| "longestStreakDays": 5 | ||
| }, | ||
| "dailyUsageBuckets": [] | ||
| }, | ||
| "profile": null, | ||
| "activityInsights": null, | ||
| "pet": { | ||
| "status": "ok", | ||
| "reason": null, | ||
| "kind": "custom", | ||
| "image": { | ||
| "role": "spritesheet", | ||
| "contentType": "image/png", | ||
| "width": 1, | ||
| "height": 1, | ||
| "byteLength": 68, | ||
| "sha256": "431ced6916a2a21a156e38701afe55bbd7f88969fbbfc56d7fe099d47f265460", | ||
| "base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
| All values are synthetic. For an available pet, `image` contains the complete | ||
| spritesheet bytes encoded as strict base64 plus validated content type, | ||
| dimensions, byte length, and lowercase SHA-256. `role` is always `spritesheet` | ||
| and `kind` is always `custom`. The human renderer prints only status, kind, | ||
| content type, dimensions, and byte length; it never prints base64 or the digest. | ||
| An unavailable pet has `status: "unavailable"`, `kind: null`, and `image: null`. | ||
| Its `reason` is one of: | ||
| - `selected_pet_state_unavailable` | ||
| - `selected_pet_not_custom` | ||
| - `selected_pet_selection_unavailable` | ||
| - `selected_pet_manifest_unavailable` | ||
| - `selected_pet_image_unavailable` | ||
| - `selected_pet_image_invalid` | ||
| - `selected_pet_image_too_large` | ||
| The v2 root status combines remote profile and pet availability: both available | ||
| is `ok`, both unavailable is `unavailable`, and every mixed case is `partial`. | ||
| The CLI exits `1` only when the combined root status is `unavailable`. | ||
| Without pet opt-in, the reader does not access the pet state, catalog, manifest, | ||
| or image and continues to emit Full Profile v1. | ||
| ## Module API | ||
| ```js | ||
| import { | ||
| listExperimentalPets, | ||
| readExperimentalProfile | ||
| } from "codex-usage-analyzer/experimental-profile"; | ||
| const catalog = await listExperimentalPets(); | ||
| const profile = await readExperimentalProfile({ | ||
| includePet: true, | ||
| selectPet(items) { | ||
| return items.find((item) => item.selected)?.key ?? null; | ||
| } | ||
| }); | ||
| ``` | ||
| `listExperimentalPets()` returns only `{ key, displayName, selected }` and does | ||
| not read image bytes. `readExperimentalProfile()` returns v1 unless | ||
| `includePet: true` is supplied. Module selection uses a numeric key returned by | ||
| `selectPet`; `forcePetSelection: true` invokes it before Desktop-state lookup. | ||
| The envelope has no separate observation timestamp. `usage.capturedAt` is the | ||
@@ -206,3 +322,3 @@ canonical time at which the official usage result was received. | ||
| Bearer and account context references exist only in process memory for the | ||
| Bearer-token and account context references exist only in process memory for the | ||
| request. They are never returned, logged, written to a file, or passed to the | ||
@@ -217,2 +333,9 @@ normalizer. JavaScript memory zeroization is not guaranteed, so the implementation | ||
| Pet opt-in is a separate local-file boundary. The reader uses bounded state, | ||
| manifest, and image reads; rejects unsafe paths, symlinks, unsupported formats, | ||
| invalid dimensions, excessive pixels, and oversized files; and returns only | ||
| normalized fields. It supports PNG and WebP custom-pet spritesheets. It never | ||
| returns local paths or raw parser errors. The base64 value is the complete image | ||
| and must be treated as private binary data. | ||
| ## Unsupported endpoint and drift | ||
@@ -243,2 +366,5 @@ | ||
| names | ||
| - obtain separate consent before retaining or publishing `pet.image.base64` | ||
| - verify strict base64, decoded byte length, SHA-256, dimensions, content type, | ||
| and a full image decode before safely re-encoding and re-hosting a pet | ||
| - validate, decode, safely re-encode, and re-host an allowed avatar instead of | ||
@@ -245,0 +371,0 @@ exposing the source URL |
+9
-1
| { | ||
| "name": "codex-usage-analyzer", | ||
| "version": "0.4.0", | ||
| "version": "0.4.1", | ||
| "description": "Read Codex account usage through the official app-server protocol.", | ||
@@ -30,2 +30,6 @@ "keywords": [ | ||
| "import": "./src/index.js" | ||
| }, | ||
| "./experimental-profile": { | ||
| "types": "./src/experimental-profile-api.d.ts", | ||
| "import": "./src/experimental-profile-api.js" | ||
| } | ||
@@ -41,2 +45,6 @@ }, | ||
| "src/errors.js", | ||
| "src/experimental-pet.js", | ||
| "src/experimental-pet-selector.js", | ||
| "src/experimental-profile-api.d.ts", | ||
| "src/experimental-profile-api.js", | ||
| "src/experimental-profile-client.js", | ||
@@ -43,0 +51,0 @@ "src/experimental-profile.js", |
+219
-33
@@ -68,32 +68,9 @@ # codex-usage-analyzer | ||
| ## Experimental profile | ||
| > **Experimental: `profile` command.** It adds identity, a 52-week token activity | ||
| > map, activity insights, and top invocations (most-used skills and plugins with | ||
| > usage counts). It uses an unsupported private endpoint and may expose private | ||
| > data. A separate `--include-pet` opt-in can also include the selected local | ||
| > custom Codex pet spritesheet. | ||
| > [Learn more about the experimental `profile` command.](#experimental-profile-command) | ||
| The default command and `--json` continue to use the documented | ||
| [`account/usage/read`](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) | ||
| method and remain identity-free. A separate explicit command can also request the | ||
| profile and activity fields shown by Codex: | ||
| ```bash | ||
| npx codex-usage-analyzer@latest profile | ||
| npx codex-usage-analyzer@latest profile --json | ||
| ``` | ||
| > **Experimental and unsupported:** `profile` uses the private | ||
| > `/wham/profiles/me` endpoint. It can change or stop working without notice. | ||
| > The command prints a warning to stderr and may emit a display name, username, | ||
| > avatar URL, plan type, activity insights, and top invocation names. | ||
| The command starts a dedicated app-server session, reads canonical usage through | ||
| `account/usage/read`, obtains the minimum ChatGPT auth context from that process, | ||
| and performs one fixed HTTPS request. The bearer token and account context stay in | ||
| process memory for that request; the CLI does not directly read authentication | ||
| files, cookies, or keychains. JavaScript cannot guarantee memory zeroization. | ||
| There is no retry, alternate endpoint, Desktop-client impersonation, or silent | ||
| fallback from the default command. `profile --json` returns a separate Full | ||
| Profile Envelope rather than extending the stable Account Usage Contract or SDK. | ||
| See the [Experimental Full Profile Contract](docs/experimental-full-profile.md) | ||
| and [JSON Schema](docs/experimental-full-profile.schema.json) before integrating | ||
| it or making identity and activity public. | ||
| ## Why this CLI | ||
@@ -146,3 +123,4 @@ | ||
| codex-usage-analyzer [usage] [--json] | ||
| codex-usage-analyzer profile [--json] (experimental) | ||
| codex-usage-analyzer profile [--json] [--include-pet] (experimental) | ||
| codex-usage-analyzer profile --include-pet [--pet-key N | --select-pet] | ||
| codex-usage-analyzer [usage|profile] --help | ||
@@ -160,6 +138,9 @@ codex-usage-analyzer --version | ||
| | `codex-usage-analyzer profile --json` | Experimental Full Profile Envelope JSON | | ||
| | `codex-usage-analyzer profile --include-pet` | Full Profile v2 with the Desktop-selected custom pet; interactive human output offers a selector when that selection is unavailable | | ||
| | `codex-usage-analyzer profile --include-pet --pet-key 2` | Full Profile v2 with catalog key `2` | | ||
| | `codex-usage-analyzer profile --include-pet --select-pet` | Force the interactive arrow-key selector; stdin and stderr must be TTYs | | ||
| | `codex-usage-analyzer --help` | Help without starting app-server | | ||
| | `codex-usage-analyzer --version` | Package version without starting app-server | | ||
| Command output is written to stdout. Failures are written to stderr as a stable error code and a safe message, without raw RPC data or app-server stderr. The experimental profile warning is always written to stderr, including successful profile calls. An unavailable private profile still emits an envelope and exits with status `1`; canonical usage remains nested when the official read succeeded. | ||
| Command output is written to stdout. Failures are written to stderr as a stable error code and a safe message, without raw RPC data or app-server stderr. The experimental profile warning is always written to stderr, including successful profile calls. Pet opt-in adds a second warning. An unavailable private profile still emits an envelope and exits with status `1`; canonical usage remains nested when the official read succeeded. | ||
@@ -187,2 +168,22 @@ ## SDK | ||
| The experimental profile and pet APIs are isolated in an explicit subpath: | ||
| ```js | ||
| import { | ||
| listExperimentalPets, | ||
| readExperimentalProfile | ||
| } from "codex-usage-analyzer/experimental-profile"; | ||
| const pets = await listExperimentalPets(); | ||
| const selectedKey = pets.find((pet) => pet.selected)?.key; | ||
| const profile = await readExperimentalProfile({ | ||
| includePet: true, | ||
| ...(selectedKey === undefined ? {} : { petKey: selectedKey }) | ||
| }); | ||
| ``` | ||
| `listExperimentalPets()` returns deterministic, one-based keys and safe display | ||
| metadata; it does not read image bytes. Module callers can instead pass | ||
| `selectPet(catalog)` and return a key. The root SDK surface remains unchanged. | ||
| ## How it works | ||
@@ -206,3 +207,3 @@ | ||
| Downstreams that explicitly accept the Full Profile Envelope must keep GitHub identity as the ownership proof, treat remote profile identity as cosmetic untrusted input, and opt in separately before storing activity insights. | ||
| Downstreams that explicitly accept the Full Profile Envelope must keep GitHub identity as the ownership proof, treat remote profile identity as cosmetic untrusted input, and opt in separately before storing activity insights or pet image bytes. | ||
@@ -220,6 +221,191 @@ ## Privacy and Security | ||
| The experimental `profile` command has a different privacy boundary: it can emit identity, an avatar source URL, plan information, activity insights, and invocation names. Inspect its JSON and the downstream privacy policy before storing, publishing, or submitting it. | ||
| The experimental `profile` command has a different privacy boundary: it can emit identity, an avatar source URL, plan information, activity insights, and invocation names. With `--include-pet`, it also reads a local custom-pet manifest and embeds the complete spritesheet as base64 plus a digest and image metadata. The base64 is the image, not a harmless identifier. Inspect its JSON and the downstream privacy policy before storing, publishing, or submitting it. | ||
| For vulnerability reporting and supported versions, see [SECURITY.md](SECURITY.md). | ||
| ## Experimental: `profile` command | ||
| The default command and `--json` continue to use the documented | ||
| [`account/usage/read`](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) | ||
| method and remain identity-free. The explicit experimental `profile` command | ||
| includes the same canonical usage and adds: | ||
| - **Profile:** display name, username, avatar information, and plan type | ||
| - **Token activity:** a 52-week map derived from canonical daily usage | ||
| - **Activity insights:** fast mode share, reasoning effort, skills explored, | ||
| total skill uses, and total threads | ||
| - **Top invocations:** most-used skills and plugins with usage counts | ||
| - **Optional custom pet:** the complete local spritesheet and validated metadata, | ||
| only when `--include-pet` is present | ||
| > **Experimental and unsupported:** `profile` uses the private | ||
| > `/wham/profiles/me` endpoint. It can change or stop working without notice. | ||
| > The command prints a warning to stderr and may emit account identity and | ||
| > private activity information. | ||
| Human-readable output: | ||
| ```bash | ||
| npx codex-usage-analyzer@latest profile | ||
| ``` | ||
| ```text | ||
| Codex profile (experimental) | ||
| Status ok | ||
| Profile | ||
| Display name Example Name | ||
| Username @example-user | ||
| Avatar Available | ||
| Plan example-plan | ||
| Usage | ||
| Codex account usage | ||
| Lifetime tokens 1.23B | ||
| Peak daily tokens 45.6M | ||
| Longest turn 12m 34s | ||
| Current streak 3 days | ||
| Longest streak 21 days | ||
| Daily buckets 4 days | ||
| Captured at 2026-07-11T00:00:00.000Z | ||
| Token activity | ||
| Sun |....................................................| | ||
| Mon |...................................................:| | ||
| Tue |...................................................*| | ||
| Wed |...................................................O| | ||
| Thu |...................................................#| | ||
| Fri |....................................................| | ||
| Sat |....................................................| | ||
| Legend . 0 : 1 * 2 O 3 # 4 | ||
| Activity insights | ||
| Fast mode 25% | ||
| Reasoning effort high (50%) | ||
| Skills explored 6 | ||
| Total skill uses 42 | ||
| Total threads 128 | ||
| Top invocations | ||
| $example-skill 9 | ||
| @example-plugin 4 | ||
| ``` | ||
| For machine-readable output: | ||
| ```bash | ||
| npx codex-usage-analyzer@latest profile --json | ||
| ``` | ||
| To include a custom pet, use one of these explicit forms: | ||
| ```bash | ||
| # Prefer the custom pet already selected in Codex Desktop. | ||
| npx codex-usage-analyzer@latest profile --json --include-pet | ||
| # Choose by the deterministic one-based catalog key. | ||
| npx codex-usage-analyzer@latest profile --json --include-pet --pet-key 2 | ||
| # Force the interactive arrow-key selector (TTY only). | ||
| npx codex-usage-analyzer@latest profile --include-pet --select-pet | ||
| ``` | ||
| Human TTY output automatically offers the selector only when the Desktop-selected | ||
| custom pet cannot be resolved. JSON output never prompts unless `--select-pet` | ||
| is explicitly present. Cancellation or an invalid selection produces a safe | ||
| unavailable `pet` result rather than silently choosing the first pet. | ||
| ```json | ||
| { | ||
| "fullProfileContractVersion": 1, | ||
| "kind": "codex-usage-analyzer.fullProfile", | ||
| "stability": "experimental", | ||
| "status": "ok", | ||
| "usage": { | ||
| "contractVersion": 1, | ||
| "capturedAt": "2026-07-11T00:00:00.000Z", | ||
| "summary": { | ||
| "lifetimeTokens": 1234567890, | ||
| "peakDailyTokens": 45600000, | ||
| "longestRunningTurnSec": 754, | ||
| "currentStreakDays": 3, | ||
| "longestStreakDays": 21 | ||
| }, | ||
| "dailyUsageBuckets": [ | ||
| { | ||
| "startDate": "2026-07-06", | ||
| "tokens": 11400000 | ||
| }, | ||
| { | ||
| "startDate": "2026-07-07", | ||
| "tokens": 22800000 | ||
| }, | ||
| { | ||
| "startDate": "2026-07-08", | ||
| "tokens": 34200000 | ||
| }, | ||
| { | ||
| "startDate": "2026-07-09", | ||
| "tokens": 45600000 | ||
| } | ||
| ] | ||
| }, | ||
| "profile": { | ||
| "displayName": "Example Name", | ||
| "username": "example-user", | ||
| "avatarUrl": "https://example.invalid/avatar.png", | ||
| "planType": "example-plan" | ||
| }, | ||
| "activityInsights": { | ||
| "fastModePercent": 25, | ||
| "reasoningEffort": "high", | ||
| "reasoningEffortPercent": 50, | ||
| "skillsExplored": 6, | ||
| "totalSkillsUsed": 42, | ||
| "totalThreads": 128, | ||
| "topInvocations": [ | ||
| { | ||
| "type": "skill", | ||
| "name": "example-skill", | ||
| "usageCount": 9 | ||
| }, | ||
| { | ||
| "type": "plugin", | ||
| "name": "example-plugin", | ||
| "usageCount": 4 | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
| > The examples above use synthetic values. An actual `profile` command returns | ||
| > the data available to the currently signed-in Codex account. | ||
| Key limitations: | ||
| - Human-readable output reports the avatar only as `Available` or `Unavailable`; | ||
| JSON includes the validated source URL, which consumers must treat as untrusted. | ||
| - The experiment makes one fixed request to an unsupported private endpoint, | ||
| without retries, alternate endpoints, Desktop-client impersonation, or fallback | ||
| from the default command. | ||
| - Authentication context remains in process memory for the request. The CLI does | ||
| not directly read authentication files, cookies, or keychains, and JavaScript | ||
| cannot guarantee memory zeroization. | ||
| - `profile --json` returns a separate experimental Full Profile Envelope; it does | ||
| not extend the stable Account Usage Contract or root SDK. | ||
| - Pet inclusion changes the envelope from v1 to v2 and adds the required `pet` | ||
| field. Without `--include-pet`, output remains Full Profile v1 compatible. | ||
| - Catalog keys are local, one-based selectors for the current catalog snapshot; | ||
| they are not persistent pet identifiers. | ||
| - Pet base64, digest, dimensions, and byte length are present in JSON. Human | ||
| output shows only safe metadata and never prints the base64 or digest. | ||
| See the [Experimental Full Profile Contract](docs/experimental-full-profile.md) | ||
| and the JSON Schemas for [v1](docs/experimental-full-profile.schema.json) and | ||
| [v2](docs/experimental-full-profile-v2.schema.json) before integrating, storing, | ||
| or publishing this output. | ||
| ## Troubleshooting | ||
@@ -226,0 +412,0 @@ |
@@ -196,3 +196,3 @@ import { spawn } from "node:child_process"; | ||
| title: "Codex Usage Analyzer", | ||
| version: "0.4.0" | ||
| version: "0.4.1" | ||
| } | ||
@@ -199,0 +199,0 @@ } |
+92
-14
@@ -6,5 +6,7 @@ import { readAccountUsage } from "./account-usage.js"; | ||
| export const PACKAGE_NAME = "codex-usage-analyzer"; | ||
| export const PACKAGE_VERSION = "0.4.0"; | ||
| export const PACKAGE_VERSION = "0.4.1"; | ||
| export const EXPERIMENTAL_PROFILE_WARNING = | ||
| "codex-usage-analyzer: Warning: profile uses an unsupported experimental endpoint and may expose account identity fields."; | ||
| export const EXPERIMENTAL_PET_WARNING = | ||
| "codex-usage-analyzer: Warning: --include-pet reads local custom pet metadata and image bytes."; | ||
@@ -16,3 +18,4 @@ const USAGE = [ | ||
| " codex-usage-analyzer [usage] [--json]", | ||
| " codex-usage-analyzer profile [--json] (experimental)", | ||
| " codex-usage-analyzer profile [--json] [--include-pet] (experimental)", | ||
| " codex-usage-analyzer profile --include-pet [--pet-key N | --select-pet]", | ||
| " codex-usage-analyzer [usage|profile] --help", | ||
@@ -25,2 +28,3 @@ " codex-usage-analyzer --version" | ||
| const stderr = io.stderr ?? process.stderr; | ||
| const stdin = io.stdin ?? process.stdin; | ||
| const parsed = parseArguments(argv); | ||
@@ -43,9 +47,41 @@ | ||
| if (parsed.action === "profile" | ||
| && parsed.selectPet | ||
| && (stdin?.isTTY !== true || stderr?.isTTY !== true)) { | ||
| stderr.write(`${USAGE}\n`); | ||
| return 1; | ||
| } | ||
| try { | ||
| if (parsed.action === "profile") { | ||
| stderr.write(`${EXPERIMENTAL_PROFILE_WARNING}\n`); | ||
| if (parsed.includePet) stderr.write(`${EXPERIMENTAL_PET_WARNING}\n`); | ||
| const readProfile = dependencies.readExperimentalProfile | ||
| ?? (await import("./experimental-profile-client.js")) | ||
| .readExperimentalProfile; | ||
| const profile = await readProfile(); | ||
| let profile; | ||
| if (parsed.includePet) { | ||
| const readOptions = { includePet: true }; | ||
| if (parsed.petKey !== null) readOptions.petKey = parsed.petKey; | ||
| const useSelector = parsed.petKey === null && ( | ||
| parsed.selectPet | ||
| || (!parsed.json && stdin?.isTTY === true && stderr?.isTTY === true) | ||
| ); | ||
| if (useSelector) { | ||
| const selectPet = dependencies.selectExperimentalPet | ||
| ?? (await import("./experimental-pet-selector.js")) | ||
| .selectExperimentalPet; | ||
| readOptions.selectPet = (catalog) => selectPet(catalog, { | ||
| input: stdin, | ||
| output: stderr | ||
| }); | ||
| } | ||
| if (parsed.selectPet) readOptions.forcePetSelection = true; | ||
| profile = await readProfile(readOptions); | ||
| } else { | ||
| profile = await readProfile(); | ||
| } | ||
| let output; | ||
@@ -95,23 +131,65 @@ | ||
| if (args.length === 0) { | ||
| return { action, json: false }; | ||
| if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { | ||
| return { action: "help" }; | ||
| } | ||
| if (args.length !== 1) { | ||
| return { action: "invalid" }; | ||
| if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) { | ||
| return { action: "version" }; | ||
| } | ||
| if (args[0] === "--json") { | ||
| return { action, json: true }; | ||
| if (action === "usage") { | ||
| if (args.length === 0) return { action, json: false }; | ||
| if (args.length === 1 && args[0] === "--json") { | ||
| return { action, json: true }; | ||
| } | ||
| return { action: "invalid" }; | ||
| } | ||
| if (args[0] === "--help" || args[0] === "-h") { | ||
| return { action: "help" }; | ||
| let json = false; | ||
| let includePet = false; | ||
| let selectPet = false; | ||
| let petKey = null; | ||
| for (let index = 0; index < args.length; index += 1) { | ||
| const argument = args[index]; | ||
| if (argument === "--json") { | ||
| if (json) return { action: "invalid" }; | ||
| json = true; | ||
| continue; | ||
| } | ||
| if (argument === "--include-pet") { | ||
| if (includePet) return { action: "invalid" }; | ||
| includePet = true; | ||
| continue; | ||
| } | ||
| if (argument === "--select-pet") { | ||
| if (selectPet) return { action: "invalid" }; | ||
| selectPet = true; | ||
| continue; | ||
| } | ||
| if (argument === "--pet-key") { | ||
| if (petKey !== null || index + 1 >= args.length) { | ||
| return { action: "invalid" }; | ||
| } | ||
| const value = args[index + 1]; | ||
| if (!/^[1-9][0-9]*$/u.test(value)) return { action: "invalid" }; | ||
| petKey = Number(value); | ||
| if (!Number.isSafeInteger(petKey)) return { action: "invalid" }; | ||
| index += 1; | ||
| continue; | ||
| } | ||
| return { action: "invalid" }; | ||
| } | ||
| if (args[0] === "--version" || args[0] === "-v") { | ||
| return { action: "version" }; | ||
| if ((petKey !== null || selectPet) && !includePet) { | ||
| return { action: "invalid" }; | ||
| } | ||
| if (petKey !== null && selectPet) return { action: "invalid" }; | ||
| return { action: "invalid" }; | ||
| return { action, json, includePet, petKey, selectPet }; | ||
| } |
@@ -9,3 +9,5 @@ import { spawn } from "node:child_process"; | ||
| createUnavailableFullProfile, | ||
| normalizeFullProfileResult | ||
| createUnavailableFullProfileV2, | ||
| normalizeFullProfileResult, | ||
| normalizeFullProfileV2Result | ||
| } from "./experimental-profile.js"; | ||
@@ -25,4 +27,5 @@ | ||
| const AUTH_STATUS_REQUEST_ID = 3; | ||
| const CLIENT_VERSION = "0.4.0"; | ||
| const CLIENT_VERSION = "0.4.1"; | ||
| const ACCOUNT_CLAIM = "https://api.openai.com/auth"; | ||
| const AUTHORIZATION_SCHEME = "Bearer"; | ||
@@ -39,2 +42,5 @@ export async function readExperimentalProfile(options = {}) { | ||
| }); | ||
| const pet = options.includePet === true | ||
| ? await readRequestedPet(options) | ||
| : null; | ||
| const planType = extractPlanType(session.accountResult); | ||
@@ -50,3 +56,5 @@ let authToken = extractAuthToken(session.authResult); | ||
| accountId = null; | ||
| return createUnavailableFullProfile(usage); | ||
| return options.includePet === true | ||
| ? createUnavailableFullProfileV2(usage, pet) | ||
| : createUnavailableFullProfile(usage); | ||
| } | ||
@@ -63,6 +71,10 @@ | ||
| if (remoteResult === null) { | ||
| return createUnavailableFullProfile(usage); | ||
| return options.includePet === true | ||
| ? createUnavailableFullProfileV2(usage, pet) | ||
| : createUnavailableFullProfile(usage); | ||
| } | ||
| return normalizeFullProfileResult(usage, remoteResult, { planType }); | ||
| return options.includePet === true | ||
| ? normalizeFullProfileV2Result(usage, remoteResult, pet, { planType }) | ||
| : normalizeFullProfileResult(usage, remoteResult, { planType }); | ||
| } finally { | ||
@@ -74,2 +86,83 @@ authToken = null; | ||
| async function readRequestedPet(options) { | ||
| let petModule; | ||
| async function loadPetModule() { | ||
| petModule ??= await import("./experimental-pet.js"); | ||
| return petModule; | ||
| } | ||
| async function readPet(petOptions) { | ||
| const reader = options.readExperimentalPet | ||
| ?? (await loadPetModule()).readExperimentalPet; | ||
| return reader(petOptions); | ||
| } | ||
| async function listPets(petOptions) { | ||
| const reader = options.listExperimentalPets | ||
| ?? (await loadPetModule()).listExperimentalPets; | ||
| return reader(petOptions); | ||
| } | ||
| const petOptions = copyPetSourceOptions(options); | ||
| try { | ||
| if (options.forcePetSelection === true) { | ||
| return await selectRequestedPet(options, petOptions, listPets, readPet); | ||
| } | ||
| if (Object.hasOwn(options, "petKey")) { | ||
| return await readPet({ ...petOptions, petKey: options.petKey }); | ||
| } | ||
| const selected = await readPet(petOptions); | ||
| if (selected?.status === "ok" || typeof options.selectPet !== "function") { | ||
| return selected; | ||
| } | ||
| return await selectRequestedPet(options, petOptions, listPets, readPet); | ||
| } catch { | ||
| return selectionUnavailablePet(); | ||
| } | ||
| } | ||
| async function selectRequestedPet( | ||
| options, | ||
| petOptions, | ||
| listPets, | ||
| readPet | ||
| ) { | ||
| if (typeof options.selectPet !== "function") { | ||
| return selectionUnavailablePet(); | ||
| } | ||
| try { | ||
| const catalog = await listPets(petOptions); | ||
| const petKey = await options.selectPet(catalog); | ||
| if (!Number.isSafeInteger(petKey) || petKey < 1) { | ||
| return selectionUnavailablePet(); | ||
| } | ||
| return await readPet({ ...petOptions, petKey }); | ||
| } catch { | ||
| return selectionUnavailablePet(); | ||
| } | ||
| } | ||
| function copyPetSourceOptions(options) { | ||
| const copied = {}; | ||
| for (const key of ["codexHome", "env", "homeDir"]) { | ||
| if (Object.hasOwn(options, key)) copied[key] = options[key]; | ||
| } | ||
| return copied; | ||
| } | ||
| function selectionUnavailablePet() { | ||
| return { | ||
| status: "unavailable", | ||
| reason: "selected_pet_selection_unavailable", | ||
| kind: null, | ||
| image: null | ||
| }; | ||
| } | ||
| async function requestExperimentalSession(options) { | ||
@@ -367,3 +460,3 @@ const spawnProcess = options.spawnProcess ?? spawn; | ||
| Accept: "application/json", | ||
| Authorization: `Bearer ${options.authToken}`, | ||
| Authorization: `${AUTHORIZATION_SCHEME} ${options.authToken}`, | ||
| "ChatGPT-Account-Id": options.accountId, | ||
@@ -370,0 +463,0 @@ originator: "codex-usage-analyzer", |
@@ -0,4 +1,14 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { ACCOUNT_USAGE_SUMMARY_FIELDS } from "./account-usage.js"; | ||
| import { | ||
| EXPERIMENTAL_PET_IMAGE_CONTENT_TYPES, | ||
| EXPERIMENTAL_PET_REASONS, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_BYTES, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_PIXELS | ||
| } from "./experimental-pet.js"; | ||
| export const FULL_PROFILE_CONTRACT_VERSION = 1; | ||
| export const FULL_PROFILE_V2_CONTRACT_VERSION = 2; | ||
| export const FULL_PROFILE_KIND = "codex-usage-analyzer.fullProfile"; | ||
@@ -36,2 +46,30 @@ export const FULL_PROFILE_STABILITY = "experimental"; | ||
| export const FULL_PROFILE_V2_FIELDS = Object.freeze([ | ||
| "fullProfileContractVersion", | ||
| "kind", | ||
| "stability", | ||
| "status", | ||
| "usage", | ||
| "profile", | ||
| "activityInsights", | ||
| "pet" | ||
| ]); | ||
| export const EXPERIMENTAL_PET_FIELDS = Object.freeze([ | ||
| "status", | ||
| "reason", | ||
| "kind", | ||
| "image" | ||
| ]); | ||
| export const EXPERIMENTAL_PET_IMAGE_FIELDS = Object.freeze([ | ||
| "role", | ||
| "contentType", | ||
| "width", | ||
| "height", | ||
| "byteLength", | ||
| "sha256", | ||
| "base64" | ||
| ]); | ||
| const PROFILE_STRING_LIMITS = Object.freeze({ | ||
@@ -96,2 +134,28 @@ displayName: 256, | ||
| export function normalizeFullProfileV2Result( | ||
| usageDocument, | ||
| remoteResult, | ||
| petResult, | ||
| options = {} | ||
| ) { | ||
| const fullProfileV1 = normalizeFullProfileResult( | ||
| usageDocument, | ||
| remoteResult, | ||
| options | ||
| ); | ||
| const pet = normalizeExperimentalPet(petResult); | ||
| return createV2Envelope(fullProfileV1, pet); | ||
| } | ||
| export function createUnavailableFullProfileV2( | ||
| usageDocument, | ||
| petResult = null | ||
| ) { | ||
| return createV2Envelope( | ||
| createUnavailableFullProfile(usageDocument), | ||
| normalizeExperimentalPet(petResult) | ||
| ); | ||
| } | ||
| function createEnvelope(status, usage, profile, activityInsights) { | ||
@@ -109,2 +173,117 @@ return { | ||
| function createV2Envelope(fullProfileV1, pet) { | ||
| return { | ||
| fullProfileContractVersion: FULL_PROFILE_V2_CONTRACT_VERSION, | ||
| kind: fullProfileV1.kind, | ||
| stability: fullProfileV1.stability, | ||
| status: resolveFullProfileV2Status(fullProfileV1.status, pet.status), | ||
| usage: fullProfileV1.usage, | ||
| profile: fullProfileV1.profile, | ||
| activityInsights: fullProfileV1.activityInsights, | ||
| pet | ||
| }; | ||
| } | ||
| function resolveFullProfileV2Status(remoteStatus, petStatus) { | ||
| if (remoteStatus === "ok" && petStatus === "ok") return "ok"; | ||
| if (remoteStatus === "unavailable" && petStatus === "unavailable") { | ||
| return "unavailable"; | ||
| } | ||
| return "partial"; | ||
| } | ||
| function normalizeExperimentalPet(value) { | ||
| if (!isRecord(value)) return unavailableExperimentalPet(); | ||
| if (value.status === "unavailable" | ||
| && EXPERIMENTAL_PET_REASONS.includes(value.reason) | ||
| && value.kind === null | ||
| && value.image === null) { | ||
| return { | ||
| status: "unavailable", | ||
| reason: value.reason, | ||
| kind: null, | ||
| image: null | ||
| }; | ||
| } | ||
| if (value.status !== "ok" | ||
| || value.reason !== null | ||
| || value.kind !== "custom") { | ||
| return unavailableExperimentalPet(); | ||
| } | ||
| const image = normalizeExperimentalPetImage(value.image); | ||
| if (image === null) return unavailableExperimentalPet(); | ||
| return { | ||
| status: "ok", | ||
| reason: null, | ||
| kind: "custom", | ||
| image | ||
| }; | ||
| } | ||
| function normalizeExperimentalPetImage(value) { | ||
| if (!isRecord(value) | ||
| || value.role !== "spritesheet" | ||
| || !EXPERIMENTAL_PET_IMAGE_CONTENT_TYPES.includes(value.contentType) | ||
| || !isPositiveBoundedInteger( | ||
| value.width, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION | ||
| ) | ||
| || !isPositiveBoundedInteger( | ||
| value.height, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_DIMENSION | ||
| ) | ||
| || value.width * value.height > MAX_EXPERIMENTAL_PET_IMAGE_PIXELS | ||
| || !isPositiveBoundedInteger( | ||
| value.byteLength, | ||
| MAX_EXPERIMENTAL_PET_IMAGE_BYTES | ||
| ) | ||
| || typeof value.sha256 !== "string" | ||
| || !/^[0-9a-f]{64}$/u.test(value.sha256) | ||
| || typeof value.base64 !== "string" | ||
| || !isStrictBase64(value.base64)) { | ||
| return null; | ||
| } | ||
| const bytes = Buffer.from(value.base64, "base64"); | ||
| if (bytes.byteLength !== value.byteLength | ||
| || createHash("sha256").update(bytes).digest("hex") !== value.sha256) { | ||
| return null; | ||
| } | ||
| return { | ||
| role: "spritesheet", | ||
| contentType: value.contentType, | ||
| width: value.width, | ||
| height: value.height, | ||
| byteLength: value.byteLength, | ||
| sha256: value.sha256, | ||
| base64: value.base64 | ||
| }; | ||
| } | ||
| function unavailableExperimentalPet() { | ||
| return { | ||
| status: "unavailable", | ||
| reason: "selected_pet_state_unavailable", | ||
| kind: null, | ||
| image: null | ||
| }; | ||
| } | ||
| function isPositiveBoundedInteger(value, maximum) { | ||
| return Number.isSafeInteger(value) && value >= 1 && value <= maximum; | ||
| } | ||
| function isStrictBase64(value) { | ||
| return value.length >= 4 | ||
| && value.length <= 11_184_812 | ||
| && value.length % 4 === 0 | ||
| && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u | ||
| .test(value); | ||
| } | ||
| function normalizeProfile(value, planType) { | ||
@@ -111,0 +290,0 @@ const normalizedPlanType = normalizeOptionalString( |
@@ -20,2 +20,6 @@ import { formatAccountUsage } from "./format-account-usage.js"; | ||
| export function formatExperimentalProfile(envelope) { | ||
| const petSection = Object.hasOwn(envelope, "pet") | ||
| ? ["", ...formatPet(envelope.pet)] | ||
| : []; | ||
| return [ | ||
@@ -26,2 +30,3 @@ "Codex profile (experimental)", | ||
| ...formatProfile(envelope.profile), | ||
| ...petSection, | ||
| "", | ||
@@ -39,2 +44,19 @@ "Usage", | ||
| function formatPet(pet) { | ||
| if (pet?.status !== "ok" || pet.kind !== "custom" || pet.image === null) { | ||
| return ["Pet", UNAVAILABLE]; | ||
| } | ||
| return [ | ||
| "Pet", | ||
| ...formatRows([ | ||
| ["Status", "Available"], | ||
| ["Kind", "custom"], | ||
| ["Content type", pet.image.contentType], | ||
| ["Dimensions", `${pet.image.width} × ${pet.image.height}`], | ||
| ["Byte length", formatInteger(pet.image.byteLength)] | ||
| ]) | ||
| ]; | ||
| } | ||
| function formatProfile(profile) { | ||
@@ -41,0 +63,0 @@ if (profile === null) return ["Profile", UNAVAILABLE]; |
+98
-1
| export declare const PACKAGE_NAME: "codex-usage-analyzer"; | ||
| export declare const PACKAGE_VERSION: "0.4.0"; | ||
| export declare const PACKAGE_VERSION: "0.4.1"; | ||
| export declare const ACCOUNT_USAGE_CONTRACT_VERSION: 1; | ||
@@ -32,2 +32,94 @@ export declare const ACCOUNT_USAGE_SUMMARY_FIELDS: readonly [ | ||
| export type ExperimentalFullProfileStatus = | ||
| | "ok" | ||
| | "partial" | ||
| | "unavailable"; | ||
| export interface ExperimentalFullProfileProfile { | ||
| displayName: string | null; | ||
| username: string | null; | ||
| avatarUrl: string | null; | ||
| planType: string | null; | ||
| } | ||
| export interface ExperimentalFullProfileInvocation { | ||
| type: "plugin" | "skill"; | ||
| name: string; | ||
| usageCount: number; | ||
| } | ||
| export interface ExperimentalFullProfileActivityInsights { | ||
| fastModePercent: number | null; | ||
| reasoningEffort: string | null; | ||
| reasoningEffortPercent: number | null; | ||
| skillsExplored: number | null; | ||
| totalSkillsUsed: number | null; | ||
| totalThreads: number | null; | ||
| topInvocations: ExperimentalFullProfileInvocation[] | null; | ||
| } | ||
| export interface ExperimentalFullProfileV1 { | ||
| fullProfileContractVersion: 1; | ||
| kind: "codex-usage-analyzer.fullProfile"; | ||
| stability: "experimental"; | ||
| status: ExperimentalFullProfileStatus; | ||
| usage: AccountUsageDocument; | ||
| profile: ExperimentalFullProfileProfile | null; | ||
| activityInsights: ExperimentalFullProfileActivityInsights | null; | ||
| } | ||
| export type ExperimentalPetReason = | ||
| | "selected_pet_state_unavailable" | ||
| | "selected_pet_not_custom" | ||
| | "selected_pet_selection_unavailable" | ||
| | "selected_pet_manifest_unavailable" | ||
| | "selected_pet_image_unavailable" | ||
| | "selected_pet_image_invalid" | ||
| | "selected_pet_image_too_large"; | ||
| export interface ExperimentalPetImage { | ||
| role: "spritesheet"; | ||
| contentType: "image/webp" | "image/png"; | ||
| width: number; | ||
| height: number; | ||
| byteLength: number; | ||
| sha256: string; | ||
| base64: string; | ||
| } | ||
| export interface ExperimentalPetAvailable { | ||
| status: "ok"; | ||
| reason: null; | ||
| kind: "custom"; | ||
| image: ExperimentalPetImage; | ||
| } | ||
| export interface ExperimentalPetUnavailable { | ||
| status: "unavailable"; | ||
| reason: ExperimentalPetReason; | ||
| kind: null; | ||
| image: null; | ||
| } | ||
| export type ExperimentalPet = | ||
| | ExperimentalPetAvailable | ||
| | ExperimentalPetUnavailable; | ||
| export interface ExperimentalPetCatalogItem { | ||
| key: number; | ||
| displayName: string | null; | ||
| selected: boolean; | ||
| } | ||
| export interface ExperimentalFullProfileV2 { | ||
| fullProfileContractVersion: 2; | ||
| kind: "codex-usage-analyzer.fullProfile"; | ||
| stability: "experimental"; | ||
| status: ExperimentalFullProfileStatus; | ||
| usage: AccountUsageDocument; | ||
| profile: ExperimentalFullProfileProfile | null; | ||
| activityInsights: ExperimentalFullProfileActivityInsights | null; | ||
| pet: ExperimentalPet; | ||
| } | ||
| export interface ReadAccountUsageOptions { | ||
@@ -65,6 +157,11 @@ timeoutMs?: number; | ||
| export interface CliIo { | ||
| stdin?: { | ||
| isTTY?: boolean; | ||
| }; | ||
| stdout?: { | ||
| isTTY?: boolean; | ||
| write(chunk: string): unknown; | ||
| }; | ||
| stderr?: { | ||
| isTTY?: boolean; | ||
| write(chunk: string): unknown; | ||
@@ -71,0 +168,0 @@ }; |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
167884
53.02%28
21.74%3582
76.71%441
72.94%6
50%