Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ MODIFY `src/oauth/chatgpt-device.ts`
poll deadline and abort. Add a service-owned per-fetch deadline (fetch +
body) so a stuck TCP cannot hold the flow until TTL. This is the Kuhn
blocker "poll timer does not bound fetch/body deadlines".
Audit-folded: one FRESH 30s timeout per fetch attempt inside the poll
loop (AbortSignal.any([ctrl.signal, AbortSignal.timeout(30_000)]), the
main-account.ts:239-241 pattern) — a single 30s signal across the whole
poll would kill the 15-minute grant. Abort-timeout maps to
device_authorization_failed. The shared helper also bounds hung POOL
device logins at 30s per fetch — an intended improvement, called out in
the PR.

MODIFY `src/codex/main-account.ts`
- New `beginNativeMainReauth`: captures the existing
Expand All @@ -43,6 +50,13 @@ MODIFY `src/codex/main-account.ts`
together, advances the mutation epoch, and reconciles runtime/quota
state. Old identity token is never retained beside new credentials. No
claim held during human polling.
Audit-folded: do NOT reuse persistRefreshedMainAuthJson (:190-195) — it
spreads expected.tokens and never writes id_token, so the old identity
token would survive beside the new grant. The commit uses a SIBLING
persist that sets access_token/refresh_token/id_token/account_id
together and overwrites any prior id_token (adding the key is safe:
readMainAuthJsonCredential :122 tolerates it and
native-profile-store.ts:476-481 expects it).

NEW `src/codex/main-device-reauth.ts`
- One process-owned active flow (opaque UUID, AbortController, bounded
Expand All @@ -68,6 +82,12 @@ MODIFY `src/cli/account-main.ts`
management API; reject extra args before start. Register capability/help;
regenerate skill surface with `bun run skill:surface` if the capability
registry changes (tests/ci-workflows/skill-ocx.test.ts gates this).
Audit-folded: the native-main CLI branch point is account-main.ts (:181
region, beside add/switch) with USAGE in src/cli/account.ts:64; the
management route-registry (src/server/management/route-registry.ts
MANAGEMENT_ROUTES) must gain the POST/GET/DELETE rows or
management-route-registry.test.ts and the capabilities ratchet go red —
do NOT grow UNDECLARED_ROUTES_2026_08_28.

## Hub fence resolution (open decision 1, resolved here for audit)

Expand Down Expand Up @@ -103,8 +123,11 @@ contract: strict keys, 400/404/409 shapes, unauthorized rejected,
`__main__` still refused by `/api/codex-auth/login`.
MODIFY `tests/oauth/chatgpt-device-auth.test.ts` — native result retains
idToken in-process; per-fetch deadline fires on a hung stub fetch.
MODIFY `tests/cli/cli-account.test.ts` — reauth --device surface, status,
cancel, arg rejection.
Audit-folded: native-main CLI tests land in
tests/cli/cli-native-profile.test.ts (native-main CLI); the pool
cli-account.test.ts keeps only the __main__ login rejection cases.
MODIFY `tests/cli/cli-native-profile.test.ts` — reauth --device surface,
status, cancel, arg rejection.
All NEW files: layout.json explicit + expected-fixture entries.

## Docs / ownership
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand All @@ -501,6 +504,8 @@ successful switch preserves local tasks and history, then requires Codex to be r
`doctor` to inspect profile state and `recover` to finish or roll back an interrupted transition.
`switch` accepts either the profile ID or its label.

`reauth` re-authenticates the *existing* native main identity with an OpenAI device code (#3898) instead of enrolling a new profile. It is the headless-hub recovery path: no local Codex App, no `codex` binary, and no OS keyring are required. The device login must complete for the same ChatGPT account that already holds the native main slot; the credential write is fenced by the exclusive claim and a path/hash/inode snapshot, and the command output carries only the flow id, the verification URL, the device code, and status. The pool login route stays pool-only and keeps rejecting `__main__`; the equivalent dashboard surface is the Codex Auth main card's Re-login with device code control.

The v1 recovery matrix covers an OpenCodex process exiting after a transaction file has been
published by rename. It does not claim durability across an OS or kernel crash or sudden power
loss: `atomicWriteFileAsync()` does not `fsync` either the file or its parent directory.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <etiket> [--json]
ocx account main add <etiket>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profil-id-veya-etiket> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1377,7 +1377,9 @@
"zz-ci-api-usage-isolation.test.ts": "ci-workflows",
"zz-ci-storage-policy-isolation.test.ts": "ci-workflows",
"zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows",
"usage-log-ws-stage.test.ts": "usage"
"usage-log-ws-stage.test.ts": "usage",
"main-device-reauth.test.ts": "codex-integration",
"main-device-reauth-api.test.ts": "codex-integration"
},
"migrated": [
"adapters",
Expand Down
27 changes: 25 additions & 2 deletions skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,29 @@ JSON mode: `payload`.
- `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched.
- Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.

### `ocx account main reauth`

Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring.

| Method | Route |
|---|---|
| POST | `/api/codex-auth/main/reauth-device` |
| GET | `/api/codex-auth/main/reauth-device` |
| DELETE | `/api/codex-auth/main/reauth-device` |

| Flag | Value | Meaning |
|---|---|---|
| `--device` | boolean | Run the device-code flow (the only reauth mode). |
| `--no-wait` | boolean | Print the flow handle and code without waiting for completion. |
| `--flow` | string | Flow id for status and cancel. |
| `--json` | boolean | Emit the flow status as JSON. |

JSON mode: `payload`.

- Same-identity reauth only: the device login must complete for the ChatGPT account that already holds the native main slot, and the commit is fenced by the exclusive claim plus a path/hash/inode snapshot.
- /api/codex-auth/login stays pool-only and keeps rejecting __main__; this namespace is the only device-reauth surface for the native main slot.
- Payloads carry only flowId, status, the verification URL, the device code, and a closed set of failure codes -- never tokens, emails, or raw account ids.

### `ocx account refresh`

Refresh account quotas without model validation; pending Codex accounts require dashboard consent.
Expand Down Expand Up @@ -769,6 +792,6 @@ JSON mode: `payload`.

## Counts

- declared capabilities: 41
- of those, state-changing: 20
- declared capabilities: 42
- of those, state-changing: 21
- head-resolved invocations: 2
80 changes: 80 additions & 0 deletions src/cli/account-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const USAGE = `Usage:
ocx account main list [--json]
ocx account main register <label> [--json]
ocx account main add <label>
ocx account main reauth --device [--no-wait] [--json]
ocx account main reauth status --flow <id> [--json]
ocx account main reauth cancel --flow <id> [--json]
ocx account main switch <profile-id-or-label> --yes [--json]
ocx account main recover [--rollback --yes] [--json]

Expand Down Expand Up @@ -186,6 +189,83 @@ export async function cmdNativeMainAccount(args: string[], deps: AccountDeps): P
const baseUrl = await resolveBaseUrl(deps);
if (!baseUrl) return proxyUnreachable();

if (sub === "reauth") {
// #3898: same-identity device reauth of the native __main__ slot via the
// dedicated management namespace; /api/codex-auth/login stays pool-only.
if (confirmed || rollback) return reject(args);
const noWait = flag(args, "--no-wait");
const device = flag(args, "--device");
const flowIndex = args.indexOf("--flow");
const flowId = flowIndex >= 0 ? args.splice(flowIndex, 2)[1] : undefined;
const action = args.shift();
if (args.length > 0) return reject(args);

const printStatus = (status: Record<string, unknown>): void => {
if (wantsJson) { console.log(JSON.stringify(status, null, 2)); return; }
console.log("status: " + String(status.status ?? "unknown"));
if (typeof status.verificationUrl === "string" && status.verificationUrl) {
console.log("url: " + status.verificationUrl);
}
if (typeof status.deviceCode === "string" && status.deviceCode) {
console.log("deviceCode: " + status.deviceCode);
}
if (typeof status.code === "string" && status.code) {
console.log("failure: " + status.code);
}
};

if (action === "status" || action === "cancel") {
if (!flowId || device || noWait) return reject(args);
const query = "/api/codex-auth/main/reauth-device?flowId=" + encodeURIComponent(flowId);
const result = action === "status"
? await apiJson(deps, baseUrl, "GET", query)
: await apiJson(deps, baseUrl, "DELETE", query);
if (result.status === 0) return proxyUnreachable(result.transportError);
if (result.status !== 200) return apiError(result.json, "failed to " + action + " the native main reauth", result.status);
printStatus(result.json);
return 0;
}

if (action !== undefined || !device) return reject(args);
const started = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/main/reauth-device");
if (started.status === 0) return proxyUnreachable(started.transportError);
if (started.status !== 200) return apiError(started.json, "failed to start the native main device reauth", started.status);
const startFlowId = typeof started.json.flowId === "string" ? started.json.flowId : "";
if (!startFlowId) {
console.error("The proxy returned an invalid reauth flow.");
return 1;
}
// The URL and human code arrive with the usercode response, a beat after start.
let pending = started.json;
const statusQuery = "/api/codex-auth/main/reauth-device?flowId=" + encodeURIComponent(startFlowId);
for (let attempt = 0; attempt < 50 && !pending.deviceCode; attempt += 1) {
await Bun.sleep(200);
const polled = await apiJson(deps, baseUrl, "GET", statusQuery);
if (polled.status === 200) pending = polled.json;
}
if (noWait) {
printStatus({ flowId: startFlowId, ...pending });
console.log("follow up: ocx account main reauth status --flow " + startFlowId);
return 0;
}
// Blocking wait bounded by the service flow expiry (15-minute grant + margin).
const deadline = Date.now() + 16 * 60_000;
for (;;) {
if (pending.status !== "pending" && pending.status !== "committing") break;
if (Date.now() >= deadline) {
console.error("The reauth flow did not finish within the device grant window; check status with --flow " + startFlowId + ".");
return 1;
}
await Bun.sleep(2_000);
const polled = await apiJson(deps, baseUrl, "GET", statusQuery);
if (polled.status === 0) return proxyUnreachable(polled.transportError);
if (polled.status !== 200) return apiError(polled.json, "failed to poll the native main reauth", polled.status);
pending = polled.json;
}
printStatus({ flowId: startFlowId, ...pending });
return pending.status === "succeeded" ? 0 : 1;
}

if (sub === "doctor" || sub === "list") {
if (args.length > 0 || confirmed || rollback) return reject(args);
const path = sub === "doctor" ? "/api/native-main-profiles/doctor" : "/api/native-main-profiles";
Expand Down
2 changes: 1 addition & 1 deletion src/cli/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const ACCOUNT_USAGE = `Usage:
ocx account cancel <provider> [--flow <flow-id>] [--json]
ocx account reset-credits <account-id|main> [--consume --yes] [--json]
ocx account grok-reset-coupons [<account-id>] [--consume --yes] [--token-id <token-id>] [--json]
ocx account main <doctor|list|register|add|switch|recover> ...
ocx account main <doctor|list|register|add|reauth|switch|recover> ...

List and switch provider accounts and API-key pools (masked output only).
'main' selects the Codex App login for the openai account pool.`;
Expand Down
22 changes: 22 additions & 0 deletions src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,28 @@ export const CAPABILITIES: readonly Capability[] = [
"Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
],
},
{
command: ["account", "main", "reauth"],
summary: "Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring.",
routes: [
{ method: "POST", path: "/api/codex-auth/main/reauth-device" },
{ method: "GET", path: "/api/codex-auth/main/reauth-device" },
{ method: "DELETE", path: "/api/codex-auth/main/reauth-device" },
],
flags: [
{ name: "--device", value: "boolean", summary: "Run the device-code flow (the only reauth mode)." },
{ name: "--no-wait", value: "boolean", summary: "Print the flow handle and code without waiting for completion." },
{ name: "--flow", value: "string", summary: "Flow id for status and cancel." },
{ name: "--json", value: "boolean", summary: "Emit the flow status as JSON." },
],
mutates: true,
json: "payload",
details: [
"Same-identity reauth only: the device login must complete for the ChatGPT account that already holds the native main slot, and the commit is fenced by the exclusive claim plus a path/hash/inode snapshot.",
"/api/codex-auth/login stays pool-only and keeps rejecting __main__; this namespace is the only device-reauth surface for the native main slot.",
"Payloads carry only flowId, status, the verification URL, the device code, and a closed set of failure codes -- never tokens, emails, or raw account ids.",
],
},
{
command: ["account", "list"],
summary: "Codex OAuth accounts with pool priority and pause state.",
Expand Down
Loading
Loading