Skip to content

Commit 9a2c1fa

Browse files
committed
feat(codex): add encrypted native main profiles
1 parent a561846 commit 9a2c1fa

19 files changed

Lines changed: 1768 additions & 13 deletions

bun.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

devlog/_plan/260801_native_main_profiles/000_design.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Native main profile switching design spike (#656)
22

3-
Status: fork-only design spike, not implementation-ready
3+
Status: implementation candidate for maintainer and security review
44

55
Issue: https://github.com/lidge-jun/opencodex/issues/656
66

@@ -200,7 +200,7 @@ Requirements:
200200
- Decrypted buffers are short-lived and cleared on a best-effort basis.
201201
- Crypto code uses a Bun-compatible `node:crypto` surface and is tested with both bundled Bun and Bun 1.4 canary.
202202

203-
The current OpenCodex tree has no suitable cross-platform OS-keyring primitive. Selecting that dependency or platform adapter is a maintainer decision and is a blocking design gate for production code. A random key in a neighboring ACL-only file is not an acceptable substitute.
203+
The implementation candidate uses `@napi-rs/keyring` 1.3.0, a Rust N-API binding over the native platform credential stores. It is loaded only for native-profile operations and has no shell, PowerShell, environment-variable, or adjacent-file fallback. A missing native binary or unavailable key store fails closed. A random key in a neighboring ACL-only file remains an unacceptable substitute.
204204

205205
## Credential capability probe
206206

@@ -452,15 +452,14 @@ The first behavior-changing PR should include:
452452

453453
A smaller read-only `doctor` PR can precede it only if maintainers want the credential-mode diagnostics separately. No PR should add a partial write path that lacks rollback proof.
454454

455-
## Open design gates
455+
## Implementation choices proposed by the PR
456456

457-
The design is ready for maintainer review after these choices are confirmed:
457+
The PR makes the recommended choices concrete instead of blocking on a separate policy round:
458458

459-
1. OS key provider: approve a cross-platform dependency or platform adapter for the vault master key.
460-
2. File-only v1: confirm that `keyring`, `auto`, and `ephemeral` should fail closed rather than delaying the entire feature.
461-
3. Stale inactive profile: allow owner transfer with a required restart when the access token is expired but the unique refresh token is present.
462-
4. Process policy: approve fail-closed busy detection and manual restart for the first PR.
463-
5. Staged login: approve invoking official `codex login` in an isolated file-mode home for adding profiles.
464-
465-
Recommended answers are yes to items 2 through 5. Item 1 needs a dependency decision before production implementation can safely begin.
459+
1. Use `@napi-rs/keyring` as the OS-protected master-key provider and fail closed without it.
460+
2. Support file-mode Codex credentials only in v1; reject `keyring`, `auto`, and `ephemeral` before writes.
461+
3. Transfer an expired target envelope without refreshing it in OpenCodex; official Codex owns refresh after restart.
462+
4. Detect native Codex processes best-effort, never terminate them, and require explicit stopped confirmation when detection is unavailable.
463+
5. Invoke official `codex login` in an ACL-restricted staging home and remove its plaintext envelope before reporting success.
466464

465+
These are reviewable implementation decisions, not claims of governance authority. Maintainers can accept, narrow, or replace them against working code and failure-injection evidence.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Native main profile implementation candidate
2+
3+
Date: 2026-08-01
4+
5+
Branch: `feat/656-native-main-profiles`
6+
7+
This increment turns the design spike into a CLI/backend-only implementation candidate. It adds an
8+
OS-keyring-backed encrypted vault, restricted official-login staging, a home-scoped transaction
9+
lock, encrypted crash journal, exact-byte auth replacement and rollback, explicit recovery, and
10+
confirmed `__main__` runtime reconciliation.
11+
12+
The management API carries profile labels, generated profile IDs, and staging IDs only. It never
13+
accepts or returns auth envelopes, access tokens, refresh tokens, raw account IDs, or decrypted
14+
payloads. All routes remain behind the existing local management origin and update-token gate.
15+
16+
The candidate intentionally excludes dashboard UI, Pool-to-native credential conversion, native
17+
process termination, non-file Codex credential stores, and plaintext key fallbacks.
18+
19+
Required publication evidence:
20+
21+
- Focused native-profile and CLI tests on packaged Bun 1.3.14 and Bun 1.4 canary.
22+
- Full typecheck and privacy scan.
23+
- Security diff review covering key custody, journal integrity, rollback failure, process detection,
24+
management authorization, secret redaction, and task/history preservation.

docs-site/src/content/docs/reference/cli.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ List and switch provider accounts and API-key pools through the running proxy. T
303303
surface is:
304304

305305
```text
306-
Usage: ocx account <list|current|use|refresh|auto-switch|remove|add-key> ...
306+
Usage: ocx account <list|current|use|refresh|auto-switch|remove|add-key|main> ...
307307
308308
List and switch provider accounts and API-key pools (GUI parity).
309309
@@ -423,6 +423,27 @@ ocx account add-key openrouter --label personal <<< "$OPENROUTER_API_KEY"
423423
security find-generic-password -w openrouter | ocx account add-key openrouter --json
424424
```
425425

426+
#### `ocx account main <subcommand>`
427+
428+
Native main login profiles change the physical Codex App/CLI identity stored in the effective
429+
`CODEX_HOME`. They are deliberately independent from `ocx account use openai`, which changes only
430+
OpenCodex Pool routing.
431+
432+
```text
433+
ocx account main doctor [--json]
434+
ocx account main list [--json]
435+
ocx account main register <label> [--json]
436+
ocx account main add <label>
437+
ocx account main switch <profile-id-or-label> --yes [--json]
438+
ocx account main recover [--rollback --yes] [--json]
439+
```
440+
441+
Version 1 supports Codex credential-store mode `file` only. Inactive full-fidelity auth envelopes
442+
are encrypted with AES-256-GCM and a random master key held by the native OS credential store.
443+
There is no plaintext or shell-command fallback. `add` runs the official `codex login` in a
444+
restricted staging home; credentials never cross the management API. Close Codex App/CLI before a
445+
switch. A successful switch preserves task/history files and requires restarting native Codex.
446+
426447
`--json` returns `{ ok: true, id: string | null, label?: string }` and never includes the key.
427448

428449
## Authentication

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"dependencies": {
5858
"@bufbuild/protobuf": "^2.12.0",
5959
"@modelcontextprotocol/sdk": "^1",
60+
"@napi-rs/keyring": "1.3.0",
6061
"bun": "1.3.14",
6162
"zod": "4.4.3"
6263
},

src/cli/account-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface AccountDeps {
3535
loadConfigImpl?: () => OcxConfig;
3636
stdinImpl?: AccountStdin;
3737
stdinTimeoutMs?: number;
38+
/** Test/platform injection for the official Codex login in a restricted staging home. */
39+
runCodexLoginImpl?: (codexHome: string) => Promise<number>;
3840
}
3941

4042
export function classifyAccount(config: OcxConfig, name: string): ClassifyResult {

src/cli/account-main.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { isAbsolute } from "node:path";
2+
import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api";
3+
4+
const USAGE = `Usage:
5+
ocx account main doctor [--json]
6+
ocx account main list [--json]
7+
ocx account main register <label> [--json]
8+
ocx account main add <label>
9+
ocx account main switch <profile-id-or-label> --yes [--json]
10+
ocx account main recover [--rollback --yes] [--json]
11+
12+
Native main login profiles change the physical Codex App/CLI login in the effective CODEX_HOME.
13+
They are independent from the OpenCodex Pool selected by 'ocx account use openai'.`;
14+
15+
interface PublicProfile {
16+
id: string;
17+
label: string;
18+
identityHint: string;
19+
state: "active" | "inactive";
20+
}
21+
22+
function flag(args: string[], name: string): boolean {
23+
const index = args.indexOf(name);
24+
if (index < 0) return false;
25+
args.splice(index, 1);
26+
return true;
27+
}
28+
29+
function reject(args: string[]): number {
30+
if (args.length > 0) console.error(`Unexpected argument(s): ${args.join(", ")}`);
31+
console.error(USAGE);
32+
return 1;
33+
}
34+
35+
function printProfiles(profiles: PublicProfile[]): void {
36+
if (profiles.length === 0) { console.log("No native main login profiles registered."); return; }
37+
const rows = profiles.map(profile => [profile.state === "active" ? "*" : " ", profile.label, profile.id, profile.identityHint]);
38+
const header = ["", "LABEL", "PROFILE ID", "IDENTITY"];
39+
const widths = header.map((value, index) => Math.max(value.length, ...rows.map(row => row[index]!.length)));
40+
const line = (columns: string[]) => columns.map((value, index) => value.padEnd(widths[index]!)).join(" ").trimEnd();
41+
console.log([line(header), ...rows.map(line)].join("\n"));
42+
}
43+
44+
async function runOfficialCodexLogin(codexHome: string): Promise<number> {
45+
const child = Bun.spawn(["codex", "login"], {
46+
env: { ...process.env, CODEX_HOME: codexHome },
47+
stdin: "inherit",
48+
stdout: "inherit",
49+
stderr: "inherit",
50+
});
51+
return child.exited;
52+
}
53+
54+
export async function cmdNativeMainAccount(args: string[], deps: AccountDeps): Promise<number> {
55+
const sub = args.shift();
56+
const wantsJson = flag(args, "--json");
57+
const confirmed = flag(args, "--yes");
58+
const rollback = flag(args, "--rollback");
59+
const baseUrl = await resolveBaseUrl(deps);
60+
if (!baseUrl) return proxyUnreachable();
61+
62+
if (sub === "doctor" || sub === "list") {
63+
if (args.length > 0 || confirmed || rollback) return reject(args);
64+
const path = sub === "doctor" ? "/api/native-main-profiles/doctor" : "/api/native-main-profiles";
65+
const result = await apiJson(deps, baseUrl, "GET", path);
66+
if (result.status === 0) return proxyUnreachable();
67+
if (result.status !== 200) return apiError(result.json, `failed to ${sub} native profiles`);
68+
if (wantsJson || sub === "doctor") console.log(JSON.stringify(result.json, null, 2));
69+
else printProfiles(Array.isArray(result.json.profiles) ? result.json.profiles as PublicProfile[] : []);
70+
return 0;
71+
}
72+
73+
if (sub === "register") {
74+
const label = args.shift();
75+
if (!label || args.length > 0 || confirmed || rollback) return reject(args);
76+
const result = await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/register", { label });
77+
if (result.status === 0) return proxyUnreachable();
78+
if (result.status !== 200) return apiError(result.json, "failed to register the current native login");
79+
if (wantsJson) console.log(JSON.stringify(result.json, null, 2));
80+
else console.log(`Registered '${label}' for ${String(result.json.effectiveCodexHome ?? "the effective CODEX_HOME")}.`);
81+
return 0;
82+
}
83+
84+
if (sub === "add") {
85+
const label = args.shift();
86+
if (!label || args.length > 0 || wantsJson || confirmed || rollback) return reject(args);
87+
const stage = await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/stage", {});
88+
if (stage.status === 0) return proxyUnreachable();
89+
if (stage.status !== 200) return apiError(stage.json, "failed to prepare native login staging");
90+
const stageId = typeof stage.json.stageId === "string" ? stage.json.stageId : "";
91+
const stagingHome = typeof stage.json.stagingCodexHome === "string" ? stage.json.stagingCodexHome : "";
92+
if (!stageId || !isAbsolute(stagingHome)) {
93+
console.error("Error: the proxy returned an invalid staging session.");
94+
return 1;
95+
}
96+
let exitCode = 1;
97+
try {
98+
console.error(`Starting official Codex login in restricted staging home: ${stagingHome}`);
99+
exitCode = await (deps.runCodexLoginImpl ?? runOfficialCodexLogin)(stagingHome);
100+
if (exitCode !== 0) throw new Error("Official Codex login did not complete successfully.");
101+
const finish = await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/stage/finish", { stageId, label });
102+
if (finish.status === 0) return proxyUnreachable();
103+
if (finish.status !== 200) return apiError(finish.json, "failed to encrypt the staged native login");
104+
console.log(`Added encrypted native profile '${label}'.`);
105+
return 0;
106+
} catch (error) {
107+
await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/stage/cancel", { stageId });
108+
console.error(`Error: ${error instanceof Error ? error.message : "Official Codex login failed."}`);
109+
return exitCode === 0 ? 1 : exitCode;
110+
}
111+
}
112+
113+
if (sub === "switch") {
114+
const target = args.shift();
115+
if (!target || args.length > 0 || rollback || !confirmed) {
116+
if (!confirmed) console.error("Close Codex App/CLI, then pass --yes to confirm it is stopped.");
117+
return reject(args);
118+
}
119+
const result = await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/switch", { target, confirmedStopped: true });
120+
if (result.status === 0) return proxyUnreachable();
121+
if (result.status !== 200) return apiError(result.json, "failed to switch the native login");
122+
if (wantsJson) console.log(JSON.stringify(result.json, null, 2));
123+
else {
124+
const profile = result.json.activeProfile as PublicProfile | undefined;
125+
console.log(`Native Codex login is now '${profile?.label ?? target}'. Restart Codex App/CLI before continuing.`);
126+
}
127+
return 0;
128+
}
129+
130+
if (sub === "recover") {
131+
if (args.length > 0 || (rollback && !confirmed)) {
132+
if (rollback && !confirmed) console.error("--rollback changes the native login and requires --yes.");
133+
return reject(args);
134+
}
135+
const result = await apiJson(deps, baseUrl, "POST", "/api/native-main-profiles/recover", { rollback });
136+
if (result.status === 0) return proxyUnreachable();
137+
if (result.status !== 200) return apiError(result.json, "failed to recover the native-profile transaction");
138+
if (wantsJson) console.log(JSON.stringify(result.json, null, 2));
139+
else console.log(result.json.recovered === true ? `Recovery completed: ${String(result.json.action ?? "converged")}.` : "No recovery journal is pending.");
140+
return 0;
141+
}
142+
143+
return reject(args);
144+
}

src/cli/account.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const ACCOUNT_USAGE = `Usage:
2929
ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
3030
ocx account cancel <provider> [--flow <flow-id>] [--json]
3131
ocx account reset-credits <account-id|main> [--consume --yes] [--json]
32+
ocx account main <doctor|list|register|add|switch|recover> ...
3233
3334
List and switch provider accounts and API-key pools (masked output only).
3435
'main' selects the Codex App login for the openai account pool.`;
@@ -264,6 +265,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
264265
if (sub === "remove") return await cmdRemove(rest, deps);
265266
if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
266267
if (sub === "add-key") return await cmdAddKey(rest, deps);
268+
if (sub === "main") {
269+
const { cmdNativeMainAccount } = await import("./account-main");
270+
return await cmdNativeMainAccount(rest, deps);
271+
}
267272
if (["login", "reauth", "code", "cancel", "reset-credits"].includes(sub ?? "")) {
268273
const { handleAccountAuthCommand } = await import("./account-auth");
269274
return await handleAccountAuthCommand(sub!, rest, deps) ?? 1;

0 commit comments

Comments
 (0)