Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ Mode-derived runtime defaults when omitted:
- Enables codex-rs compact prompt + `summary_prefix` handoff behavior for OpenAI sessions.
- Mode defaults: `true` in `codex`, `false` in `native`.
- Explicit boolean value overrides mode default.
- `runtime.shareableDebug: boolean`
- Writes a bounded privacy-first summary log to `<config-root>/logs/codex-plugin/shareable-debug.jsonl`.
- Persists a rolling crash-tolerant event buffer under `<config-root>/logs/codex-plugin/shareable-debug-state/segments/`.
- On trigger conditions (`401`, `403`, `429`, auth failures, account-switch retry after failure, and synthetic plugin fatal errors), writes a dedicated incident file under `<config-root>/logs/codex-plugin/shareable-debug-state/incidents/`.
- Uses `<config-root>/logs/codex-plugin/shareable-debug-state/incident-state.json` to resume or seal interrupted incident capture after restart.
- Sensitive identifiers are pseudonymized per process/log bundle instead of logged raw.
- Request bodies, tokens, cookies, OAuth secrets, raw emails/account IDs/session IDs, and raw `prompt_cache_key` values are never persisted by this mode.
- When enabled, request snapshot logging is suppressed even if `runtime.headerSnapshots` or `runtime.headerTransformDebug` are also set.
- `runtime.headerSnapshots: boolean`
- Writes before/after request header snapshots to debug logs.
- Custom snapshot metadata is stored under a nested `meta` object to prevent collisions with reserved top-level fields.
Expand Down Expand Up @@ -316,6 +324,7 @@ Advanced path:
### Debug/OAuth controls

- `OPENCODE_OPENAI_MULTI_DEBUG=1`: plugin debug logs.
- `OPENCODE_OPENAI_MULTI_SHAREABLE_DEBUG=1`: privacy-first shareable structured debug log.
- `CODEX_IN_VIVO=1`: enables live quota probe tests.
- `DEBUG_CODEX_PLUGIN=1`: alternate debug flag.
- `CODEX_AUTH_DEBUG=1`: verbose OAuth lifecycle logging (`oauth-lifecycle.log`).
Expand Down
11 changes: 11 additions & 0 deletions docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
- pinned prompt cache metadata (`lastChecked`, URLs, ETags)
- `<config-root>/logs/codex-plugin/` (optional)
- request/response snapshot logs when enabled
- `<config-root>/logs/codex-plugin/shareable-debug.jsonl` (optional)
- bounded privacy-first structured debug summary log with per-process pseudonyms for account/session correlation
- `<config-root>/logs/codex-plugin/shareable-debug-state/segments/` (optional)
- rolling crash-tolerant pseudonymized event buffer used for pre-incident context
- `<config-root>/logs/codex-plugin/shareable-debug-state/incidents/` (optional)
- dedicated pseudonymized before/after incident captures around error triggers
- `<config-root>/logs/codex-plugin/shareable-debug-state/incident-state.json` (optional)
- recovery manifest for interrupted incident capture
- `<config-root>/logs/codex-plugin/oauth-lifecycle.log` (optional)
- OAuth lifecycle debug log when `CODEX_AUTH_DEBUG` is enabled

Expand Down Expand Up @@ -74,6 +82,9 @@ Recommended additional local ignore patterns (not auto-managed by plugin):
- Snapshot writer redacts sensitive auth headers/tokens before persistence.
- Snapshot writer also redacts sensitive account/session metadata keys and sensitive URL query values.
- Live-headers snapshots redact `prompt_cache_key` values.
- Shareable debug mode never writes raw tokens, cookies, OAuth secrets, raw emails/account IDs/identity keys/session IDs, raw `prompt_cache_key` values, or raw request bodies.
- Shareable debug pseudonyms are stable only within a single process/log bundle.
- Shareable debug incident capture keeps a rolling on-disk pseudonymized buffer so before-error context can survive process exit or crash.
- If request body capture is enabled, prompt/tool payload content may still be written; use short-lived debugging windows only.
- OAuth debug lifecycle logs rotate at a configurable size cap.

Expand Down
8 changes: 8 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ Optional OAuth debug log rotation:

- `CODEX_AUTH_DEBUG_MAX_BYTES`

Safer issue-reporting path:

- `OPENCODE_OPENAI_MULTI_SHAREABLE_DEBUG=1`
- Writes a bounded `shareable-debug.jsonl` summary log under the plugin log directory.
- Keeps a rolling pseudonymized buffer under `shareable-debug-state/segments/`.
- On auth/fatal trigger conditions, writes dedicated incident captures under `shareable-debug-state/incidents/`.
- Use this when you need logs that are intended to be safe to paste into a public issue.

Sensitive auth headers/tokens are redacted in snapshot logs.
Sensitive account/session metadata keys and URL query values are redacted as well.
If request body capture is enabled, prompt/tool payload content may still be present.
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getHeaderSnapshotBodiesEnabled,
getHeaderTransformDebugEnabled,
getHeaderSnapshotsEnabled,
getShareableDebugEnabled,
getOrchestratorSubagentsEnabled,
getMode,
getRemapDeveloperMessagesToUserEnabled,
Expand Down Expand Up @@ -153,6 +154,7 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => {
compatInputSanitizer: getCompatInputSanitizerEnabled(cfg),
remapDeveloperMessagesToUser: getRemapDeveloperMessagesToUserEnabled(cfg),
codexCompactionOverride: getCodexCompactionOverrideEnabled(cfg),
shareableDebug: getShareableDebugEnabled(cfg),
headerSnapshots: getHeaderSnapshotsEnabled(cfg),
headerSnapshotBodies: getHeaderSnapshotBodiesEnabled(cfg),
headerTransformDebug: getHeaderTransformDebugEnabled(cfg),
Expand Down
16 changes: 15 additions & 1 deletion lib/codex-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import { createSessionAffinityRuntimeState } from "./codex-native/session-affinity-state.js"
import { initializeCatalogSync, selectCatalogAuthCandidate } from "./codex-native/catalog-sync.js"
import { createOpenAIFetchHandler } from "./codex-native/openai-loader-fetch.js"
import { createShareableDebugLogger } from "./shareable-debug.js"
export { browserOpenInvocationFor } from "./codex-native/browser.js"
export { upsertAccount } from "./codex-native/accounts.js"
export { extractAccountId, extractAccountIdFromClaims, refreshAccessToken } from "./codex-native/oauth-utils.js"
Expand Down Expand Up @@ -163,6 +164,7 @@ export type CodexAuthPluginOptions = {
compatInputSanitizer?: boolean
remapDeveloperMessagesToUser?: boolean
codexCompactionOverride?: boolean
shareableDebug?: boolean
headerSnapshots?: boolean
headerSnapshotBodies?: boolean
headerTransformDebug?: boolean
Expand Down Expand Up @@ -355,11 +357,22 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO
...(spoofMode === "native" ? { openaiBeta: "responses=experimental" } : {})
}
}
const shareableDebugEnabled = opts.shareableDebug === true
if (shareableDebugEnabled && (opts.headerSnapshots === true || opts.headerTransformDebug === true)) {
opts.log?.warn("shareable debug disables request snapshot logging", {
headerSnapshots: opts.headerSnapshots === true,
headerTransformDebug: opts.headerTransformDebug === true
})
}
const requestSnapshots = createRequestSnapshots({
enabled: opts.headerSnapshots === true || opts.headerTransformDebug === true,
enabled: !shareableDebugEnabled && (opts.headerSnapshots === true || opts.headerTransformDebug === true),
captureBodies: opts.headerSnapshotBodies === true,
log: opts.log
})
const shareableDebug = createShareableDebugLogger({
enabled: shareableDebugEnabled,
log: opts.log
})
const catalogModelsByScope = new Map<string, CodexModelInfo[]>()
const catalogRequestMetadataBySession = new Map<
string,
Expand Down Expand Up @@ -523,6 +536,7 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO
configuredRotationStrategy: opts.rotationStrategy,
headerTransformDebug: opts.headerTransformDebug === true,
compatInputSanitizerEnabled: opts.compatInputSanitizer === true,
shareableDebug,
internalCatalogScopeHeader: INTERNAL_CATALOG_SCOPE_HEADER,
internalSelectedModelHeader: INTERNAL_SELECTED_MODEL_HEADER,
internalCollaborationModeHeader: INTERNAL_COLLABORATION_MODE_HEADER,
Expand Down
54 changes: 54 additions & 0 deletions lib/codex-native/acquire-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AccountRecord, OpenAIAuthMode, RotationStrategy } from "../types.j
import { parseJwtClaims } from "../claims.js"
import { formatAccountLabel } from "./accounts.js"
import { extractAccountId, refreshAccessToken, type OAuthTokenRefreshError } from "./oauth-utils.js"
import type { ShareableDebugLogger } from "../shareable-debug.js"

const AUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000
const AUTH_REFRESH_LEASE_MS = 30_000
Expand Down Expand Up @@ -73,6 +74,7 @@ export type AcquireOpenAIAuthInput = {
pidOffsetEnabled: boolean
configuredRotationStrategy?: RotationStrategy
log?: Logger
shareableDebug?: ShareableDebugLogger
}

export function createAcquireOpenAIAuthInputDefaults(): {
Expand Down Expand Up @@ -114,6 +116,17 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
let totalAccounts = 0
let rotationLogged = false
let lastSelectionTrace: AccountSelectionTrace | undefined
const emitAuthFailure = async (details: { outcome: string; status: number; waitMs?: number }): Promise<void> => {
await input.shareableDebug?.emitAuthFailure({
authMode: input.authMode,
outcome: details.outcome,
status: details.status,
waitMs: details.waitMs,
sessionKey: input.context?.sessionKey,
selectedIdentityKey: lastSelectionTrace?.selectedIdentityKey,
activeIdentityKey: lastSelectionTrace?.activeIdentityKey
})
}

try {
while (true) {
Expand Down Expand Up @@ -163,6 +176,14 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
mode: input.authMode,
sessionKey: input.context?.sessionKey ?? null
})
await input.shareableDebug?.emitRotationBegin({
authMode: input.authMode,
rotationStrategy,
activeIdentityKey: domain.activeIdentityKey,
totalAccounts: domain.accounts.length,
enabledAccounts: enabled.length,
sessionKey: input.context?.sessionKey ?? null
})
rotationLogged = true
}

Expand Down Expand Up @@ -211,6 +232,20 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
...(event.sessionKey ? { sessionKey: event.sessionKey } : null)
}
input.log?.debug("rotation decision", event)
void input.shareableDebug?.emitRotationDecision({
authMode: input.authMode,
rotationStrategy: event.strategy,
decision: event.decision,
totalCount: event.totalCount,
disabledCount: event.disabledCount,
cooldownCount: event.cooldownCount,
refreshLeaseCount: event.refreshLeaseCount,
eligibleCount: event.eligibleCount,
attemptedCount: attempted.size + (event.selectedIdentityKey ? 1 : 0),
selectedIdentityKey: event.selectedIdentityKey,
activeIdentityKey: event.activeIdentityKey,
sessionKey: event.sessionKey
})
}
})

Expand Down Expand Up @@ -247,6 +282,15 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
selectedCooldownUntil: selected.cooldownUntil ?? null,
selectedExpires: selected.expires ?? null
})
void input.shareableDebug?.emitRotationCandidateSelected({
authMode: input.authMode,
attemptKey,
selectedIdentityKey: selected.identityKey,
selectedIndex,
selectedEnabled: selected.enabled !== false,
selectedCooldownUntil: selected.cooldownUntil ?? null,
selectedExpires: selected.expires ?? null
})
if (lastSelectionTrace) {
lastSelectionTrace = {
...lastSelectionTrace,
Expand Down Expand Up @@ -474,6 +518,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
const authSnapshot = await loadAuthStorage(undefined, { lockReads: false })
const openai = authSnapshot.openai
if (!openai || openai.type !== "oauth") {
await emitAuthFailure({ outcome: "oauth_not_configured", status: 401 })
throw new PluginFatalError({
message: "Not authenticated with OpenAI. Run `opencode auth login`.",
status: 401,
Expand All @@ -485,6 +530,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
const domain = ensureOpenAIOAuthDomain(authSnapshot, input.authMode)
const enabledAfterAttempts = domain.accounts.filter((account) => account.enabled !== false)
if (enabledAfterAttempts.length === 0 && sawInvalidGrant) {
await emitAuthFailure({ outcome: "refresh_invalid_grant", status: 401 })
throw new PluginFatalError({
message:
"All enabled OpenAI refresh tokens were rejected (invalid_grant). Run `opencode auth login` to reauthenticate.",
Expand All @@ -506,6 +552,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<

if (nextAvailableAt !== undefined) {
const waitMs = Math.max(0, nextAvailableAt - now)
await emitAuthFailure({ outcome: "all_accounts_cooling_down", status: 429, waitMs })
throw new PluginFatalError({
message: `All enabled OpenAI accounts are cooling down. Try again in ${formatWaitTime(waitMs)} or run \`opencode auth login\`.`,
status: 429,
Expand All @@ -515,6 +562,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}

if (sawInvalidGrant) {
await emitAuthFailure({ outcome: "refresh_invalid_grant", status: 401 })
throw new PluginFatalError({
message:
"OpenAI refresh token was rejected (invalid_grant). Run `opencode auth login` to reauthenticate this account.",
Expand All @@ -525,6 +573,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}

if (sawMissingRefresh) {
await emitAuthFailure({ outcome: "missing_refresh_token", status: 401 })
throw new PluginFatalError({
message: "Selected OpenAI account is missing a refresh token. Run `opencode auth login` to reauthenticate.",
status: 401,
Expand All @@ -534,6 +583,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}

if (sawMissingIdentity) {
await emitAuthFailure({ outcome: "missing_account_identity", status: 401 })
throw new PluginFatalError({
message: "Selected OpenAI account is missing identity metadata. Run `opencode auth login` to reauthenticate.",
status: 401,
Expand All @@ -543,6 +593,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}

if (sawRefreshFailure) {
await emitAuthFailure({ outcome: "refresh_failed", status: 401 })
throw new PluginFatalError({
message: "Failed to refresh OpenAI access token. Run `opencode auth login` and try again.",
status: 401,
Expand All @@ -551,6 +602,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
})
}

await emitAuthFailure({ outcome: "no_enabled_accounts", status: 403 })
throw new PluginFatalError({
message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
status: 403,
Expand All @@ -560,6 +612,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}
} catch (error) {
if (isPluginFatalError(error)) throw error
await emitAuthFailure({ outcome: "auth_storage_error", status: 500 })
throw new PluginFatalError({
message:
"Unable to access OpenAI auth storage. Check plugin configuration and run `opencode auth login` if needed.",
Expand All @@ -570,6 +623,7 @@ export async function acquireOpenAIAuth(input: AcquireOpenAIAuthInput): Promise<
}

if (!access) {
await emitAuthFailure({ outcome: "no_valid_access_token", status: 401 })
throw new PluginFatalError({
message: "No valid OpenAI access token available. Run `opencode auth login`.",
status: 401,
Expand Down
Loading
Loading