-
Notifications
You must be signed in to change notification settings - Fork 974
feat(codex): opt-in reset-credit auto-redemption before expiry (#822) #3219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # wp13 — #822 opt-in reset-credit auto-redemption (slice 1: policy + ledger + tests) | ||
|
|
||
| Investigation (grok subagent Volta). Today: inspect (`GET .../wham/rate-limit-reset-credits`) and | ||
| manual consume (`POST .../consume` with a fresh `redeem_request_id` per call) in | ||
| `src/codex/auth-api.ts`, CLI `ocx account reset-credits`, dashboard button. An unused #657 ledger | ||
| (`reset-credit-operation-ledger.ts`, kinds `recovery|manual`) exists. No auto-redeem config. | ||
|
|
||
| ## Slice 1 (this cycle) | ||
| - Config: `resetCreditAutoRedeem: { enabled: boolean; leadTimeMinutes?: 1..60 }` (default off; malformed | ||
| → disabled with one warning). Types + zod `.catch(undefined)`. | ||
| - `src/codex/reset-credit-auto-redeem.ts`: pure policy `planAutoRedeem(now, credits, settings)` → nearest | ||
| unused credit with parseable `expires_at` and its due time `expires_at - lead`; identity | ||
| `{accountId, grantedAt, expiresAt}`; `shouldDispatch(refreshedCredits, plan)` re-validates the | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 4 '\b(shouldDispatch|creditStillPresent)\b' \
src/codex/reset-credit-auto-redeem.ts \
tests/codex-reset-credit-auto-redeem.test.tsRepository: lidge-jun/opencodex Length of output: 1665 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- document context ---'
sed -n '1,35p' devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md
printf '%s\n' '--- scoped repository knowledge files ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 \
-maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- helper-name occurrences in the relevant source and document scopes ---'
rg -n -C 3 '\b(shouldDispatch|creditStillPresent)\b' \
src/codex devlog/_plan/260902_nonbug_adoption_backlogRepository: lidge-jun/opencodex Length of output: 6280 Use
🤖 Prompt for AI Agents |
||
| identity after a fresh inspect. Ledger kind `"auto-redeem"` with one operationId reused as | ||
| `redeem_request_id` per identity (crash-safe idempotency). | ||
| - Scheduler: `startResetCreditAutoRedeem(config, deps)` registered from `src/server/index.ts` only when | ||
| enabled, teardown via `registerOptionalShutdownHook`; timer fire = refresh + re-check, never blind | ||
| redeem. Logs hashed account key only. | ||
| - Docs row in server.md. No GUI. | ||
|
|
||
| ## Acceptance | ||
| - Default off: no timer, no import cost on core files (core-lab boundary test green). | ||
| - Fake clock + fake WHAM: schedules at expiry-lead; identity change / disable / manual consume first → | ||
| skip; dispatch reuses the same redeem_request_id across a simulated restart; success re-reads balance. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # wp13 audit r1 — synthesis | ||
|
|
||
| Volta (grok-4.6): expiry-triggered, default-off, generation-keyed identity, fresh inspect before | ||
| dispatch, one redeem_request_id per identity in a new ledger kind, activation only from the | ||
| composition root. Adopted; scheduler included in slice 1 because policy without a trigger closes nothing. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # wp14 — #2816 + PR #2817 opt-in upstream Responses WebSocket transport (carry) | ||
|
|
||
| Investigation (grok subagent Turing): opt-in `providers.<name>.upstreamWebsocket` boolean; hook in | ||
| `providerFetch` via `shouldUseCodexWsUpstream`; HTTPS `/responses` only; SSE fallback on any | ||
| pre-open failure (426 included); fail-closed `response.done` mapping; no core-lab or startServer | ||
| changes; no body/token logging. macOS red on the PR head was the known `server-auth` websocket | ||
| passthrough flake; Linux shards green. 139 behind dev, one conflict in provider-routes.ts POST | ||
| overwrite block (retainModels/displayNames vs upstreamWebsocket omit-preserve). | ||
|
|
||
| ## Decision | ||
| Carry by merge in a side worktree (/tmp/ocx-wp14-c94721, branch `codex/carry-2817-upstream-ws`); conflict resolved by | ||
| subagent keeping both omit-preserves; tsc/privacy/focused green at `d4914f52d`. Land via new PR, | ||
| close #2817 as landed-via-carry, close #2816. | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # wp14 audit r1 — synthesis | ||
|
|
||
| Turing (grok-4.6): carry + fix, not reimplement. Blockers: rebase + resolve provider-routes.ts keeping | ||
| both omit-preserves; exact-head CI (macOS server-auth ws flake to be treated as flake). Verdict near-pass. | ||
| Merge executed by Aristotle (grok-4.6) in the side worktree: resolved block keeps `existing` early, | ||
| samples `submittedUpstreamWebsocket` before enrich, preserves on omit; 153 pass / 1 skip. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # wp15 — #2495 + PR #2496 opt-in plaintext V2 collaboration rewrite | ||
|
|
||
| Investigation (grok subagent James): head e4b88af4f, 14 ahead / 4 unique vs dev, +2729 / 18 files, draft, | ||
| CHANGES_REQUESTED on an older head, exact-head CI blocked on fork approval; last executed suite red on | ||
| two PR-specific assertions (plaintext alias rebuild after quota retry; WS relay rewrite). Depends on | ||
| undocumented ChatGPT/Codex behavior (reserved namespace/tool renames to defeat Fernet encryption; | ||
| `encrypted_function_args: []` receive path). Core-lab boundary clean; no body logging. | ||
|
|
||
| ## Disposition | ||
| Close PR #2496 with rationale; keep #2495 open with the reopen conditions. Not merged: protocol rewrite | ||
| keyed off undocumented upstream behavior, no exact-head green, reviewer blockers not re-reviewed, and a | ||
| smaller slice would not close the issue. Estimated honest merge path 8–12h with a maintainer-owned | ||
| rebase and security pass; not this batch. | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -389,6 +389,49 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } { | |
| return { code: typeof obj.code === "string" ? obj.code : "unknown" }; | ||
| } | ||
|
|
||
| /** | ||
| * Background reset-credit access for the auto-redeemer (#822). Goes through the same | ||
| * account/lease wrapper as the management routes, but takes a caller-owned | ||
| * `redeem_request_id` so a journaled id can be replayed idempotently after a crash. | ||
| * Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous. | ||
| */ | ||
| export function createResetCreditWhamClient(config: OcxConfig, accountId: string): { | ||
| inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>; | ||
| consume: (redeemRequestId: string) => Promise<{ code: string }>; | ||
| } { | ||
| const run = async <T>(operation: (auth: ResetCreditAuth) => Promise<T>): Promise<T> => { | ||
| const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation); | ||
| if (result.ok) return result.value; | ||
| throw new Error(`reset-credit auth unavailable (${result.response.status})`); | ||
| }; | ||
| return { | ||
| inspect: () => run(async auth => { | ||
| const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", { | ||
| headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId }, | ||
| signal: AbortSignal.timeout(8000), | ||
| }); | ||
|
Comment on lines
+409
to
+412
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- scoped conventions ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f -name '*.md' -print
for f in /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/*/*.md; do
case "$f" in
*/learnings/*|*/architecture/*) continue ;;
esac
printf '\n--- %s ---\n' "$f"
sed -n '1,220p' "$f"
done
printf '%s\n' '--- repository runtime/version declarations ---'
git ls-files | grep -E '(^|/)(AGENTS\.md|package\.json|bunfig\.toml|\.bun-version|Dockerfile[^/]*|README\.md|.*lock.*)$' | head -80
for f in AGENTS.md package.json bunfig.toml .bun-version README.md; do
if [ -f "$f" ]; then
printf '\n--- %s ---\n' "$f"
sed -n '1,220p' "$f"
fi
done
printf '%s\n' '--- exact candidate source ---'
sed -n '390,440p' src/codex/auth-api.tsRepository: lidge-jun/opencodex Length of output: 43602 🌐 Web query:
💡 Result: In Bun, the fetch API's handling of redirects and Authorization headers follows the WHATWG Fetch Standard [1][2]. Default Redirect Behavior By default, Bun's fetch API follows redirects (equivalent to redirect: "follow") [3]. You can control this behavior using the redirect option in RequestInit, which supports values like "follow", "error", or "manual" [4][5]. Authorization Header and Redirects Bun adheres to the Fetch Standard's security requirements regarding sensitive headers [1][2]. Specifically: 1. Cross-Origin Redirects: If a redirect leads to a different origin (where scheme, host, and port are not identical), Bun will strip sensitive headers—including Authorization—from the redirected request to prevent credential leakage [1][6][2]. 2. Same-Origin Redirects: If the redirect remains within the same origin, the Authorization header is preserved [1][6]. Note on Implementation: While earlier versions of Bun faced issues with the persistence of these headers during redirects, these have been addressed in line with the Fetch specification [1][6]. Additionally, if you provide credentials via URL userinfo (e.g., http://user:pass@host/), Bun now derives an Authorization: Basic header from those credentials and strips them from the URL itself, ensuring the credentials do not leak into the request URL [7]. Documentation References For further details, refer to the official Bun networking documentation [8] and the WHATWG Fetch Standard [2]. Be aware that certain bugs or inconsistencies related to the Request.redirect property have been reported in the past [9], so verify your specific Bun version if you encounter unexpected redirect behavior. Citations:
🌐 Web query:
💡 Result: Under the WHATWG Fetch specification, user agents are required to remove the "Authorization" header when a request undergoes a cross-origin redirect [1][2][3]. This security measure is intended to prevent the inadvertent leaking of sensitive authentication credentials to a different origin than the one the developer originally intended to authorize [2][4]. Regarding your query about the "ChatGPT-Account-Id" header: 1. Scope of Authorization Removal: The Fetch specification specifically defines the "Authorization" header as a CORS non-wildcard request-header name [5]. It is this specific header that is automatically dropped by compliant browsers and Fetch implementations upon encountering a cross-origin redirect [1][6]. 2. Custom Headers: Other custom headers, including "ChatGPT-Account-Id", are not subject to this specific automatic removal rule defined in the Fetch specification for cross-origin redirects [5]. Unless a specific implementation or middleware has its own logic to strip custom headers, "ChatGPT-Account-Id" will typically persist across redirects [7][4]. 3. ChatGPT/Codex Context: Technical documentation and discussions surrounding the OpenAI Codex CLI and related integrations indicate that "ChatGPT-Account-Id" is a custom header used to manage workspace identity and authentication policy [8][9]. Because this is an application-specific header rather than the standard HTTP "Authorization" header, it is not subject to the WHATWG's automatic stripping behavior [5]. Developers interacting with these endpoints must ensure their own client-side logic correctly handles these headers when redirects occur, as the browser or Fetch implementation will not automatically sanitize them for security [7]. If you are encountering issues where "ChatGPT-Account-Id" is missing or causing errors after a redirect, it is likely due to application-level logic or specific server-side constraints (such as workspace restriction enforcement) rather than the standard behavior of the Fetch API [9]. Citations:
🌐 Web query:
💡 Result: When using the Fetch API, the behavior regarding headers—specifically Authorization and other sensitive headers—during a redirect is strictly governed by the Fetch specification to prevent security vulnerabilities [1][2]. When a redirect occurs, the browser (or a spec-compliant runtime like Bun) checks if the target of the redirect is same-origin with the request's current URL [1]. 1. Cross-Origin Redirects: If the redirect target has a different origin (different scheme, host, or port), the user agent will automatically strip sensitive headers from the redirected request [1]. Specifically, headers such as Authorization, Cookie, and other non-wildcard CORS request-header names are removed to prevent leaking credentials to untrusted third-party servers [3][1][4]. This is a security measure required by the Fetch standard to adhere to RFC 9110 [3]. 2. Same-Origin Redirects: If the redirect target is same-origin, these headers are typically preserved because the request remains within the same security context [4]. If your application requires these headers to be present on the final destination after a cross-origin redirect, you must manually handle the redirect rather than relying on the automatic follow behavior [5]. This can be achieved by: - Setting the redirect mode to manual (redirect: 'manual') in your fetch options [5]. - Inspecting the response status code (e.g., 301, 302, 307, 308) and the Location header. - Manually issuing a new fetch request to the target URL with the necessary headers re-applied. This approach gives you full control over the request headers, allowing you to intentionally send authentication tokens only to trusted domains [5]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import urllib.request
url = "https://raw.githubusercontent.com/whatwg/fetch/main/fetch.bs"
text = urllib.request.urlopen(url, timeout=10).read().decode()
needle = "If request's current URL's origin is not same origin with location URL's origin"
pos = text.find(needle)
print(text[pos:pos+5000] if pos >= 0 else "redirect algorithm text not found")
PYRepository: lidge-jun/opencodex Length of output: 2443 Sensitive Data Exposure (CWE-201) Reachability: External · Exploitability: Difficult Reject redirects on credentialed WHAM requests. Bun 1.4.0 follows redirects by default and strips 🤖 Prompt for AI Agents |
||
| if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } | ||
| const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000)); | ||
| if (!parsed.ok) throw new Error("invalid upstream reset-credit response"); | ||
| return { credits: safeResetCreditsDto(parsed.value).credits }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: printf '%s\n' '--- repository guidance ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- applicable source guidance ---'
if [ -f AGENTS.md ]; then sed -n '1,220p' AGENTS.md; fi
if [ -f src/AGENTS.md ]; then sed -n '1,220p' src/AGENTS.md; fi
printf '%s\n' '--- relevant diff ---'
git diff -- src/codex/auth-api.ts
printf '%s\n' '--- target definitions and callers ---'
sed -n '330,455p' src/codex/auth-api.ts
rg -n -C 5 'safeResetCreditsDto|safeResetCreditConsumeDto|resetCredits|consume\(' src/codex/auth-api.ts srcRepository: lidge-jun/opencodex Length of output: 50377 🏁 Script executed: printf '%s\n' '--- source conventions and learnings ---'
sed -n '1,220p' /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/conventions/src.md
sed -n '1,220p' /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learnings/src.md
printf '%s\n' '--- auto-redeemer implementation ---'
sed -n '1,245p' src/codex/reset-credit-auto-redeem.ts
printf '%s\n' '--- auto-redeemer symbols and tests ---'
rg -n -C 8 'createResetCreditWhamClient|inspect|consume|journal|schedule|ambiguous|redeemRequestId|reset-credit auto-redeem' src/codex/reset-credit-auto-redeem.ts testsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: printf '%s\n' '--- auto-redeemer implementation ---'
sed -n '1,245p' src/codex/reset-credit-auto-redeem.ts
printf '%s\n' '--- reset-credit tests ---'
files=$(rg -l 'reset-credit|reset credit|redeemRequestId|safeResetCreditsDto|safeResetCreditConsumeDto' tests -g '*.test.ts')
printf '%s\n' "$files"
for f in $files; do
printf '\n--- %s ---\n' "$f"
rg -n -C 6 'reset-credit|reset credit|redeemRequestId|inspect|consume|journal|schedule|ambiguous|malformed|invalid' "$f"
doneRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: printf '%s\n' '--- auth API reset-credit tests ---'
rg -n -C 10 'rate-limit-reset-credits|createResetCreditWhamClient|reset-credit|consume.*code|credits' tests/codex-auth-api.test.ts tests/rate-limit-reset-credits.test.ts
printf '%s\n' '--- consume endpoint implementation ---'
sed -n '2045,2165p' src/codex/auth-api.ts
printf '%s\n' '--- response reader implementation ---'
sed -n '435,535p' src/codex/auth-api.tsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: sed -n '2100,2165p' src/codex/auth-api.ts
sed -n '435,525p' src/codex/auth-api.tsRepository: lidge-jun/opencodex Length of output: 6006 Reject malformed successful WHAM payloads. At At Use strict parsers. Accept an empty list only when 🤖 Prompt for AI Agents |
||
| }), | ||
| consume: redeemRequestId => run(async auth => { | ||
| const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${auth.accessToken}`, | ||
| "ChatGPT-Account-Id": auth.chatgptAccountId, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ redeem_request_id: redeemRequestId }), | ||
| signal: AbortSignal.timeout(10_000), | ||
| }); | ||
| if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } | ||
| return safeResetCreditConsumeDto(await resp.json()); | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| type ResetCreditJsonRead = | ||
| | { ok: true; value: unknown } | ||
| | { ok: false }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import { existsSync, readFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { atomicWriteFile } from "../config/atomic-write"; | ||
| import { getConfigDir } from "../config/paths"; | ||
| import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks"; | ||
| import type { OcxConfig } from "../types"; | ||
|
|
||
| /** | ||
| * Opt-in auto-redemption of a Codex reset credit shortly before it expires (#822). | ||
| * | ||
| * Default off. When enabled, the nearest unexpired credit for the main Codex account is | ||
| * redeemed `leadTimeMinutes` before its `expires_at`. Every fire re-reads the upstream credit | ||
| * list first and dispatches only when the same credit (granted_at + expires_at) is still | ||
| * present, so a credit the operator already spent by hand is never redeemed twice. The | ||
| * `redeem_request_id` for a credit identity is minted once and journaled to disk before the | ||
| * consume call, so a crash between dispatch and settle replays the same idempotent request | ||
| * instead of spending a second credit. Logs carry a hashed account key only. | ||
| */ | ||
|
|
||
| export interface ResetCreditAutoRedeemSettings { | ||
| enabled: boolean; | ||
| leadTimeMinutes: number; | ||
| } | ||
|
|
||
| export const DEFAULT_LEAD_TIME_MINUTES = 10; | ||
| export const MIN_LEAD_TIME_MINUTES = 1; | ||
| export const MAX_LEAD_TIME_MINUTES = 60; | ||
|
|
||
| export function resolveResetCreditAutoRedeemSettings(config: Pick<OcxConfig, "resetCreditAutoRedeem">): ResetCreditAutoRedeemSettings { | ||
| const raw = config.resetCreditAutoRedeem; | ||
| if (!raw || raw.enabled !== true) return { enabled: false, leadTimeMinutes: DEFAULT_LEAD_TIME_MINUTES }; | ||
| const lead = typeof raw.leadTimeMinutes === "number" && Number.isInteger(raw.leadTimeMinutes) | ||
| ? Math.min(Math.max(raw.leadTimeMinutes, MIN_LEAD_TIME_MINUTES), MAX_LEAD_TIME_MINUTES) | ||
| : DEFAULT_LEAD_TIME_MINUTES; | ||
| return { enabled: true, leadTimeMinutes: lead }; | ||
| } | ||
|
|
||
| export interface ResetCredit { | ||
| granted_at: string; | ||
| expires_at: string; | ||
| } | ||
|
|
||
| export interface AutoRedeemPlan { | ||
| /** Stable identity of the credit being protected. */ | ||
| grantedAt: string; | ||
| expiresAt: string; | ||
| /** Epoch ms at which the redeem should be attempted. */ | ||
| dueAt: number; | ||
| } | ||
|
|
||
| /** Pick the credit that expires soonest and is still in the future; null when nothing qualifies. */ | ||
| export function planAutoRedeem(now: number, credits: readonly ResetCredit[], settings: ResetCreditAutoRedeemSettings): AutoRedeemPlan | null { | ||
| if (!settings.enabled) return null; | ||
| let best: AutoRedeemPlan | null = null; | ||
| for (const credit of credits) { | ||
| const expires = Date.parse(credit.expires_at); | ||
| if (!Number.isFinite(expires) || expires <= now) continue; | ||
| const dueAt = expires - settings.leadTimeMinutes * 60_000; | ||
| if (!best || expires < Date.parse(best.expiresAt)) best = { grantedAt: credit.granted_at, expiresAt: credit.expires_at, dueAt }; | ||
| } | ||
| return best; | ||
| } | ||
|
|
||
| export function creditStillPresent(credits: readonly ResetCredit[], plan: Pick<AutoRedeemPlan, "grantedAt" | "expiresAt">): boolean { | ||
| return credits.some(c => c.granted_at === plan.grantedAt && c.expires_at === plan.expiresAt); | ||
| } | ||
|
|
||
| interface JournalEntry { | ||
| accountKey: string; | ||
| grantedAt: string; | ||
| expiresAt: string; | ||
| redeemRequestId: string; | ||
| state: "dispatched" | "settled"; | ||
| updatedAt: number; | ||
| } | ||
|
|
||
| interface Journal { version: 1; entries: JournalEntry[] } | ||
|
|
||
| export function journalPath(): string { | ||
| return join(getConfigDir(), "reset-credit-auto-redeem.json"); | ||
| } | ||
|
|
||
| function readJournal(path: string): Journal { | ||
| if (!existsSync(path)) return { version: 1, entries: [] }; | ||
| try { | ||
| const parsed = JSON.parse(readFileSync(path, "utf8")) as Journal; | ||
| return parsed && parsed.version === 1 && Array.isArray(parsed.entries) ? parsed : { version: 1, entries: [] }; | ||
| } catch { | ||
| return { version: 1, entries: [] }; | ||
| } | ||
| } | ||
|
|
||
| function writeJournal(path: string, journal: Journal): void { | ||
| // Keep only entries whose credit could still matter: settled ones older than a week are noise. | ||
| const cutoff = Date.now() - 7 * 24 * 60 * 60_000; | ||
| journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff); | ||
| atomicWriteFile(path, JSON.stringify(journal, null, 2)); | ||
| } | ||
|
|
||
| export function hashAccountKey(accountId: string): string { | ||
| return createHash("sha256").update(accountId).digest("hex").slice(0, 12); | ||
| } | ||
|
|
||
| export interface AutoRedeemDeps { | ||
| accountId: string; | ||
| settings: () => ResetCreditAutoRedeemSettings; | ||
| /** Fresh upstream read of the credit list; throws on auth/transport failure. */ | ||
| inspect: () => Promise<{ credits: ResetCredit[] }>; | ||
| /** Consume with a caller-owned idempotency key. Returns the upstream code. */ | ||
| consume: (redeemRequestId: string) => Promise<{ code: string }>; | ||
| now?: () => number; | ||
| setTimer?: (fn: () => void, ms: number) => unknown; | ||
| clearTimer?: (handle: unknown) => void; | ||
| journalFile?: string; | ||
| log?: (line: string) => void; | ||
| /** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */ | ||
| maxSleepMs?: number; | ||
| /** Interval to re-inspect when no credit is due yet (default 30 min). */ | ||
| idleRecheckMs?: number; | ||
| } | ||
|
|
||
| export type AutoRedeemOutcome = | ||
| | { kind: "disabled" } | ||
| | { kind: "nothing-to-protect" } | ||
| | { kind: "scheduled"; dueAt: number } | ||
| | { kind: "skipped"; reason: "credit-gone" | "disabled-before-dispatch" } | ||
| | { kind: "dispatched"; code: string; redeemRequestId: string } | ||
| | { kind: "ambiguous"; redeemRequestId: string } | ||
| | { kind: "error"; message: string }; | ||
|
|
||
| export interface ResetCreditAutoRedeemer { | ||
| /** Inspect, and either dispatch (if due) or schedule the next check. */ | ||
| tick(): Promise<AutoRedeemOutcome>; | ||
| start(): void; | ||
| stop(): void; | ||
| } | ||
|
|
||
| export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCreditAutoRedeemer { | ||
| const now = deps.now ?? (() => Date.now()); | ||
| const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms)); | ||
| const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as ReturnType<typeof setTimeout>)); | ||
| const log = deps.log ?? ((line: string) => console.log(line)); | ||
| const path = deps.journalFile ?? journalPath(); | ||
| const accountKey = hashAccountKey(deps.accountId); | ||
| const maxSleepMs = deps.maxSleepMs ?? 15 * 60_000; | ||
| const idleRecheckMs = deps.idleRecheckMs ?? 30 * 60_000; | ||
| let handle: unknown = null; | ||
| let stopped = false; | ||
| let inFlight: Promise<AutoRedeemOutcome> | null = null; | ||
|
|
||
| const schedule = (ms: number): void => { | ||
| if (stopped) return; | ||
| if (handle !== null) clearTimer(handle); | ||
| handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs))); | ||
| }; | ||
|
|
||
| const dispatch = async (plan: AutoRedeemPlan): Promise<AutoRedeemOutcome> => { | ||
| const journal = readJournal(path); | ||
| let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt); | ||
| if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" }; | ||
| if (!entry) { | ||
| entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() }; | ||
| journal.entries.push(entry); | ||
| // Journal BEFORE the network call: a crash after this line replays the same request id. | ||
| writeJournal(path, journal); | ||
| } | ||
| log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`); | ||
| let result: { code: string }; | ||
| try { | ||
| result = await deps.consume(entry.redeemRequestId); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Block consumption after If Add a stopped-generation guard inside 🤖 Prompt for AI Agents |
||
| } catch (error) { | ||
| log(`[opencodex] reset-credit auto-redeem: consume uncertain for account ${accountKey}; will retry with the same request id`); | ||
| schedule(60_000); | ||
| return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId }; | ||
| } | ||
| entry.state = "settled"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When WHAM returns HTTP 200 with a missing or newly introduced AGENTS.md reference: src/AGENTS.md:L17-L17 Useful? React with 👍 / 👎. |
||
| entry.updatedAt = now(); | ||
| writeJournal(path, journal); | ||
| log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`); | ||
| schedule(idleRecheckMs); | ||
| return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId }; | ||
| }; | ||
|
|
||
| const tick = async (): Promise<AutoRedeemOutcome> => { | ||
| if (inFlight) return inFlight; | ||
| inFlight = (async () => { | ||
| const settings = deps.settings(); | ||
| if (!settings.enabled) return { kind: "disabled" } as AutoRedeemOutcome; | ||
| let credits: ResetCredit[]; | ||
| try { | ||
| ({ credits } = await deps.inspect()); | ||
| } catch (error) { | ||
| schedule(idleRecheckMs); | ||
| return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome; | ||
| } | ||
| const plan = planAutoRedeem(now(), credits, settings); | ||
| if (!plan) { schedule(idleRecheckMs); return { kind: "nothing-to-protect" } as AutoRedeemOutcome; } | ||
| if (plan.dueAt > now()) { schedule(plan.dueAt - now()); return { kind: "scheduled", dueAt: plan.dueAt } as AutoRedeemOutcome; } | ||
| // Due: re-read right before spending. The plan above came from this same inspect, but | ||
| // the settings may have flipped and a manual consume may have raced; check both again. | ||
| if (!deps.settings().enabled) return { kind: "skipped", reason: "disabled-before-dispatch" } as AutoRedeemOutcome; | ||
| let fresh: ResetCredit[]; | ||
| try { ({ credits: fresh } = await deps.inspect()); } catch (error) { | ||
| schedule(60_000); | ||
| return { kind: "error", message: error instanceof Error ? error.message : "inspect failed" } as AutoRedeemOutcome; | ||
| } | ||
| if (!creditStillPresent(fresh, plan)) { schedule(idleRecheckMs); return { kind: "skipped", reason: "credit-gone" } as AutoRedeemOutcome; } | ||
| return dispatch(plan); | ||
| })().finally(() => { inFlight = null; }); | ||
| return inFlight; | ||
| }; | ||
|
|
||
| return { | ||
| tick, | ||
| start() { stopped = false; void tick(); }, | ||
| stop() { stopped = true; if (handle !== null) { clearTimer(handle); handle = null; } }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If shutdown calls Useful? React with 👍 / 👎. |
||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Composition-root activation. Returns the redeemer only when the opt-in is on; the caller | ||
| * (src/server/index.ts) must not await this and must gate on `enabled` itself so a default | ||
| * install never constructs the timer. | ||
| */ | ||
| export function activateResetCreditAutoRedeem( | ||
| config: OcxConfig, | ||
| wham: Pick<AutoRedeemDeps, "inspect" | "consume" | "accountId">, | ||
| ): ResetCreditAutoRedeemer { | ||
| const redeemer = createResetCreditAutoRedeemer({ | ||
| ...wham, | ||
| settings: () => resolveResetCreditAutoRedeemSettings(config), | ||
| }); | ||
| const unregister = registerOptionalShutdownHook("reset-credit-auto-redeem", () => { redeemer.stop(); unregister(); }); | ||
| redeemer.start(); | ||
| return redeemer; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix the repeated MD022 heading-spacing issue.
Add one blank line after each affected
##heading.devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md#L8-L8: add a blank line after## Slice 1 (this cycle).devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md#L21-L21: add a blank line after## Acceptance.devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md#L10-L10: add a blank line after## Decision.devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md#L9-L9: add a blank line after## Disposition.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 8-8: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
📍 Affects 3 files
devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md#L8-L8(this comment)devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md#L21-L21devlog/_plan/260902_nonbug_adoption_backlog/140_wp14_upstream_ws_carry.md#L10-L10devlog/_plan/260902_nonbug_adoption_backlog/150_wp15_plaintext_v2_disposition.md#L9-L9🤖 Prompt for AI Agents
Source: Linters/SAST tools