diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51014e332..420c22944 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -432,6 +432,70 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help + # Keep every OS credential-store check on a disposable GitHub-hosted machine. + # A force-cancelled process cannot run its in-process finally cleanup, so no + # keyring matrix leg may use the persistent self-hosted Windows runner. + keyring-smoke: + name: keyring ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 8 + strategy: + fail-fast: false + matrix: + include: + - name: ubuntu + runner: ubuntu-latest + - name: windows + runner: windows-latest + - name: macos + runner: macos-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Linux hosted runners do not provide an unlocked desktop keyring session. + # Run Secret Service with temporary state instead of skipping Linux. + - name: Install Secret Service dependencies (Linux) + if: matrix.name == 'ubuntu' + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends dbus-x11 gnome-keyring + + - name: OS keyring create/read/delete smoke + shell: bash + run: | + set -euo pipefail + if [ "$RUNNER_OS" != "Linux" ]; then + bun run scripts/keyring-smoke.ts + exit 0 + fi + + keyring_home="$(mktemp -d)" + runtime_dir="$(mktemp -d)" + cleanup() { rm -rf -- "$keyring_home" "$runtime_dir"; } + trap cleanup EXIT + chmod 700 "$keyring_home" "$runtime_dir" + HOME="$keyring_home" XDG_RUNTIME_DIR="$runtime_dir" dbus-run-session -- bash -euo pipefail -c ' + # Create and unlock the isolated login keyring without putting its + # ephemeral password in argv, the environment, or the job log. + od -An -N32 -tx1 /dev/urandom | + tr -d "[:space:]" | + gnome-keyring-daemon --unlock --components=secrets >/dev/null + bun run scripts/keyring-smoke.ts + ' + npm-global-smoke: name: npm-global ${{ matrix.os }} needs: changes @@ -507,7 +571,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, gates, platform-macos, platform-windows, npm-global-smoke] + needs: [changes, select-windows-runner, test, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 225b9e94a..0bc641bc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: exit 1 fi - # opencodex is bun-native (the prepublishOnly GUI build + typecheck run under bun). + # opencodex is bun-native (the prepublishOnly audit, GUI build, and typecheck run under bun). - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -92,6 +92,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Dependency audit (high severity) + run: bun run audit:high + - name: Verify version matches package.json env: RELEASE_VERSION: ${{ inputs.version }} @@ -103,6 +106,8 @@ jobs: exit 1; } + # The exact-SHA CI gate includes the hosted Linux, Windows, and macOS + # keyring smoke matrix. Do not duplicate its Linux bootstrap here. - name: Require successful Cross-platform CI for this commit env: GH_TOKEN: ${{ github.token }} @@ -138,17 +143,15 @@ jobs: fi ci_url="$( - # shellcheck disable=SC2016 - # `$branch` is a jq variable bound by --arg, not a shell variable, so - # the filter must stay single-quoted. gh run list \ --workflow ci.yml \ + --branch "${GITHUB_REF#refs/heads/}" \ --commit "$GITHUB_SHA" \ + --event push \ --status success \ --limit 10 \ - --json conclusion,headSha,url,workflowName,event,headBranch \ - --jq --arg branch "${GITHUB_REF#refs/heads/}" \ - 'map(select(.event == "push" and .headBranch == $branch)) | .[0].url // ""' + --json url \ + --jq '.[0].url // ""' )" if [ -z "$ci_url" ]; then diff --git a/bun.lock b/bun.lock index 7da1b7b91..3ab941dd6 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@bufbuild/protobuf": "^2.12.0", "@modelcontextprotocol/sdk": "^1", + "@napi-rs/keyring": "1.3.0", "bun": "1.3.14", "zod": "4.4.3", }, @@ -29,6 +30,32 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], diff --git a/devlog/_plan/260801_native_main_profiles/000_design.md b/devlog/_plan/260801_native_main_profiles/000_design.md new file mode 100644 index 000000000..64a6dd5ba --- /dev/null +++ b/devlog/_plan/260801_native_main_profiles/000_design.md @@ -0,0 +1,465 @@ +# Native main profile switching design spike (#656) + +Status: implementation candidate for maintainer and security review + +Issue: https://github.com/lidge-jun/opencodex/issues/656 + +Official Codex reference: `openai/codex@ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff` + +## Decision summary + +The first implementation should be CLI/backend-only and should use these safety boundaries: + +1. Support native ChatGPT credentials only when the effective Codex credential store is `file`. +2. Treat the complete native auth envelope as opaque bytes. Parse it for validation, but never rebuild it from Pool fields. +3. Keep exactly one refresh owner. Official Codex owns the active profile; the encrypted OpenCodex vault owns inactive profiles. +4. Move a profile between those owners transactionally. Do not create a second independently refreshable Pool record. +5. Require encrypted storage for inactive profiles, backed by an OS-protected key. There is no plaintext fallback. +6. Bind every vault and switch transaction to one canonical effective `CODEX_HOME`. +7. Refuse a live switch while a native Codex process can still refresh the old credential. Drain OpenCodex `__main__` traffic and require a native Codex restart. +8. Preserve task and history data. Only the active credential and `__main__` runtime-derived state may change. +9. Prove rollback for every failure after the first credential write before opening a behavior-changing PR. +10. Keep GUI work out of the first PR. + +This design deliberately rejects `keyring`, `auto`, and `ephemeral` Codex credential modes in v1. Supporting those modes requires an official Codex integration point; directly reproducing Codex keyring internals in TypeScript would be brittle and unsafe. + +## Why the Pool credential model cannot be reused + +The current Pool record contains the fields OpenCodex needs to route and refresh Pool traffic. It is not the complete native Codex credential record. + +The current official Codex `AuthDotJson` includes: + +- `auth_mode` +- `OPENAI_API_KEY` +- `tokens` +- `last_refresh` +- `agent_identity` +- `personal_access_token` +- `bedrock_api_key` + +The ChatGPT token payload includes the raw ID token, access token, refresh token, and optional account ID. The format can gain fields independently of OpenCodex. + +Relevant official definitions: + +- [AuthDotJson](https://github.com/openai/codex/blob/ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff/codex-rs/login/src/auth/storage.rs#L38) +- [TokenData](https://github.com/openai/codex/blob/ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff/codex-rs/login/src/token_data.rs#L10) + +Converting a Pool record into `auth.json` would lose native fields and assign refresh ownership to two independent implementations. That is not a supported conversion path. + +## Official Codex storage constraints + +Official Codex currently has four credential-store modes: + +| Mode | Official behavior | v1 behavior | +| --- | --- | --- | +| `file` | Stores credentials in `$CODEX_HOME/auth.json` | Supported | +| `keyring` | Stores credentials in the keyring and fails if unavailable | Refuse | +| `auto` | Prefers keyring and falls back to `auth.json` | Refuse because the active backend can change | +| `ephemeral` | Keeps credentials in process memory only | Refuse | + +The official default is currently `file`. On Windows, the default keyring backend stores encrypted secrets in a local file whose key is in the OS keyring. A successful keyring save can remove `auth.json`. + +Relevant official definitions: + +- [AuthCredentialsStoreMode](https://github.com/openai/codex/blob/ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff/codex-rs/config/src/types.rs#L104) +- [AuthKeyringBackendKind](https://github.com/openai/codex/blob/ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff/codex-rs/config/src/types.rs#L136) +- [Auth storage backends](https://github.com/openai/codex/blob/ee0247f95a6fe2b094ba2253d82cae2a2b4c2dff/codex-rs/login/src/auth/storage.rs#L163) + +Therefore, the presence or absence of `auth.json` alone does not identify the official active credential source. The capability probe must resolve the credential mode before any write. + +## Threat model + +The feature must protect against: + +- Token disclosure through logs, CLI output, management responses, crash journals, or diagnostic bundles. +- Writing to a different Codex home than the one shown to the user. +- Losing the original login after malformed input, failed validation, failed rename, failed ACL application, or failed read-back. +- Two components using the same rotating refresh token independently. +- A process crash between the auth-file replacement and the vault metadata update. +- A native Codex process refreshing the source profile while OpenCodex is switching it out. +- Stale in-flight `__main__` responses publishing usage or plan state for the old identity. +- Clearing tasks or history as a side effect of an account switch. +- Treating a missing, malformed, or temporarily unreadable credential as a confirmed logout or identity change. + +It does not claim to protect an unlocked account from malware running as the same OS user. The OS credential store and file ACLs are still required to reduce accidental disclosure and offline credential exposure. + +## Invariants + +### I1. Full-fidelity envelope + +OpenCodex stores the exact source bytes and their SHA-256 digest. It may parse a copy for validation, but it must write the original decrypted bytes without serializing a reduced TypeScript shape. + +This preserves fields that the installed Codex understands but the installed OpenCodex does not. + +### I2. One refresh owner + +Credential ownership is stateful: + +| Profile state | Credential owner | Refresh allowed by OpenCodex | +| --- | --- | --- | +| Active | Official Codex active store | No | +| Inactive | Encrypted OpenCodex vault | No | +| Staged login | Official Codex in a restricted temporary home | No | +| Switching | Transaction journal under an exclusive switch lock | No | + +OpenCodex never calls the OAuth refresh grant for native profiles. The Pool refresh implementation remains Pool-only. + +On a successful switch, the target encrypted payload is consumed from the inactive vault record and becomes the official active credential. The source active envelope becomes the encrypted inactive record. The target record retains metadata and an active marker, not a second refreshable payload. + +### I3. Exact home binding + +The operation resolves `CODEX_HOME` once, canonicalizes it, and computes: + +```text +home_id = SHA-256("opencodex-native-profile-home-v1\0" || canonical_home_bytes) +``` + +The vault, encrypted payload AAD, lock, and journal all carry this `home_id`. A mismatch is a hard error. An invalid explicit `CODEX_HOME` is never replaced with a fallback path. + +The CLI displays the canonical path used for the operation. The vault may store only `home_id`; it does not need to persist the raw path. + +### I4. No plaintext inactive profile + +After a successful command returns, an inactive native envelope exists only as authenticated ciphertext. Temporary plaintext created for staged official login is ACL-restricted and removed before success is reported. + +There is no `--allow-plaintext`, environment-variable bypass, or automatic fallback when key storage is unavailable. + +### I5. Unknown reads are not transitions + +Missing, malformed, and unreadable active credentials are `unknown` states. They stop the switch without changing the vault, runtime state, active profile marker, or task affinity. + +### I6. Commit before runtime reset + +`__main__` quota, cooldown, reauth, plan, cache, thread affinity, and WebSockets are cleared only after both the credential replacement and vault ownership transfer have committed. + +### I7. Task and history preservation + +No transaction step may write, move, truncate, or delete Codex task, history, rollout, session, or project files. A different account may be unable to continue a server-side task, but OpenCodex does not rewrite that task to hide the incompatibility. + +## Storage model + +The vault contains public metadata and authenticated ciphertext. Token-bearing fields are always inside `payload`. + +```ts +type NativeMainProfileVaultV1 = { + version: 1; + revision: number; + homeId: string; + activeProfileId: string | null; + profiles: NativeMainProfileRecordV1[]; +}; + +type NativeMainProfileRecordV1 = { + id: string; + label: string; + identityHash: string; + identityHint: string; + state: "active" | "inactive"; + payload: EncryptedEnvelopeV1 | null; + createdAt: string; + updatedAt: string; +}; + +type EncryptedEnvelopeV1 = { + cipher: "aes-256-gcm"; + keyRef: string; + nonce: string; + ciphertext: string; + tag: string; + envelopeSha256: string; +}; +``` + +`identityHint` is a user-safe, masked value. Email and raw account IDs are not required for listing. The authenticated additional data binds the ciphertext to: + +```text +format_version || home_id || profile_id || identity_hash || envelope_sha256 +``` + +The vault and journal use `atomicWriteFileAsync()` so Windows ACL application remains asynchronous and happens before publication by rename. In this design, "published" means that the rename completed and a later OpenCodex process can observe the transaction file. `atomicWriteFileAsync()` does not `fsync` either the file or its parent directory. Version 1 therefore covers recovery after an OpenCodex process exit at a published transaction phase; it does not claim durability across an OS or kernel crash or sudden power loss. + +## Key custody + +The implementation needs a narrow key-provider boundary: + +```ts +interface NativeProfileKeyProvider { + getOrCreate(homeId: string): Promise<{ + keyRef: string; + key: Uint8Array; + }>; +} +``` + +Requirements: + +- The production provider stores the random 256-bit master key in an OS-protected credential store. +- The vault file never contains the master key. +- A key-store failure is terminal and leaves native auth unchanged. +- Tests inject an in-memory deterministic provider. +- Decrypted buffers are short-lived and cleared on a best-effort basis. +- Crypto code uses a Bun-compatible `node:crypto` surface and is tested with both bundled Bun and Bun 1.4 canary. + +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. + +## Credential capability probe + +Every mutating command runs the following read-only probe first: + +1. Resolve and canonicalize the effective `CODEX_HOME`. +2. Resolve the installed Codex credential-store mode. +3. Accept only an explicit or default `file` result. +4. Reject `keyring`, `auto`, and `ephemeral` with a specific error and no writes. +5. Read `auth.json` once and classify it as `ok`, `missing`, `invalid`, or `unreadable`. +6. Parse the complete JSON only to validate the ChatGPT envelope and derive a stable identity. +7. Report the canonical home, store mode, active identity hint, and feature availability without returning credentials. + +`auto` is rejected even when `auth.json` currently exists. Official Codex may later save to keyring and remove the file, so treating that file as authoritative would create split ownership. + +## Enrollment + +Two enrollment paths are needed. + +### Register the current active profile + +`ocx account main register