Skip to content

feat: encrypted export bundles and bundle import (CLI) - #119

Merged
7678837-glitch merged 2 commits into
devfrom
feat/export-import-bundle
Oct 19, 2026
Merged

7678837-glitch merged 2 commits into
devfrom
feat/export-import-bundle

Conversation

@7678837-glitch

@7678837-glitch 7678837-glitch commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a CLI for moving the whole codex-switch store to another machine:

  • codex-switch export [FILE] writes every saved ChatGPT profile, custom provider (API key and models.json included) and config.toml into one passphrase-encrypted .csbundle.
  • codex-switch import <bundle> detects a bundle by its content and restores it offline. It supports a dry-run preview and skip / overwrite / merge conflict strategies, keeps a backup and journal, and rolls back on error.

The legacy import <auth.json | dir> [alias] behavior is unchanged. There is no TUI entry in this PR.

Approved decisions

  1. Copy only. Nothing is retired on the source machine, and there is no --retire. Both export and import print a prominent stderr warning: refresh tokens are single-use, so if one account is used on two machines, one of them hits refresh_token_reused and must run codex-switch login <alias>.
  2. New export command; import sniffs content. A file whose top-level JSON has "format": "codex-switch-export" takes the bundle path. Anything else takes the unchanged legacy path. Bundle-only flags (--strategy, --dry-run, --yes, --activate, --passphrase-stdin) on a legacy path, or an alias with a bundle, are reported as a clap usage error (exit 2). Clap cannot see file contents, so this check runs after the sniff and uses Cli::command().error(ArgumentConflict, …).
  3. Offline import by default. There is no Usage API call. The managed workspace policy (validate_managed_auth_value), the non-empty account_id requirement, the file credential-store gate and the identity rules still apply. Any invalid entry aborts the import before the first write.
  4. config.toml is included by default (encrypted). The dry-run and the import flag [proxy] as machine-specific, review. --no-config (and --no-providers) exclude it.
  5. Crypto is ring 0.17 (already in the lockfile) plus argon2 0.5.3, using Argon2id with m = 64 MiB, t = 3, p = 1. zeroize (already transitive) is now a direct dependency, used to wipe the passphrase, derived keys and decrypted buffers. New crates in the lockfile: argon2, blake2, base64ct, password-hash (the last one is pulled in by argon2's manifest).

Bundle format

One UTF-8 JSON document, default name codex-switch-<YYYYMMDD-HHMMSS>.csbundle. It is deliberately not .json, so the legacy directory scan ignores it. It is written 0600 (a file-only DACL on Windows), the parent directory's mode is left alone, and an existing file is refused unless --force is given.

{
  "format": "codex-switch-export", "schema_version": 1, "app_version": "…",
  "platform": {"os": "linux", "arch": "x86_64"}, "created_at": "RFC3339 UTC",
  "encryption": {"scheme": "argon2id-aes256gcm",
                 "kdf": {"m_cost_kib": 65536, "t_cost": 3, "p_cost": 1, "salt": "b64 16B"},
                 "nonce": "b64 12B", "key_check": "b64 32B"},
  "ciphertext": "b64"            // encrypted bundles
  // plaintext bundles instead: "encryption": null, "payload_sha256": "hex", "payload": {…}
}

The payload is {source_current, entries: [{key, kind, text}]}. Keys are logical paths relative to the app home, never absolute. Only profiles/<alias>/auth.json, providers/<alias>/provider.toml, providers/<alias>/models.json and config.toml are accepted, and every alias goes through validate_alias. .., \, :, a leading /, any other file name, duplicate keys and kind/key mismatches are hard errors before any write. text keeps the file bytes, so a create round-trips byte for byte. The header is parsed with deny_unknown_fields. A minimal {format, schema_version} probe runs first, so a newer schema is refused before the passphrase prompt. The migrate() hook exists, and tests/fixtures/export-v1.csbundle is checked in as a compatibility fixture.

Not exported: cache.json, logs, deleted-profiles/, recovery/, provider-runs/, locks, update cache, and every Codex-owned file ($CODEX_HOME/auth.json, its .bak copies and config.toml).

Encryption

  1. Argon2id(passphrase, 16-byte random salt) produces 64 bytes. The first 32 bytes are the AES-256-GCM key; the last 32 bytes are the HMAC-SHA256 key-check key.
  2. key_check = HMAC(check_key, "codex-switch-export key check v1").
  3. The AAD is the serialized header (format, schema_version, app_version, platform, created_at, encryption), so the KDF parameters, salt and nonce are all authenticated.
  4. A 12-byte random nonce is used for each export.

Errors carry distinct --json code values:

Situation code
KDF parameters out of bounds (8 MiB ≤ m ≤ 256 MiB, 1 ≤ t ≤ 6, 1 ≤ p ≤ 4 — tightened in review round 1, exact salt, nonce and check lengths); checked before deriving unsupported_encryption
Key check fails (this also covers an edited salt or KDF parameters) wrong_passphrase
Key check passes but GCM open fails (ciphertext, tag or any other header field edited); also a plaintext checksum mismatch bundle_tampered
Schema newer than this build ("run codex-switch self-update") unsupported_bundle_version
format missing or wrong not_a_bundle
Malformed or unsafe content invalid_bundle

The JSON error envelope gained an optional code field. Existing errors are unchanged because the field is omitted when None.

Passphrase input: export prompts twice through rpassword, and import prompts once. In non-interactive use, --passphrase-stdin reads the first line of stdin. The passphrase is never read from argv or an environment variable. --plaintext requires a typed confirmation, or --yes when there is no TTY or --json is set. It prints a warning on export and on import, and import suggests deleting the file afterwards.

Conflict handling

Profiles match on identity: the same account_id and lowercase email. Providers match on the same alias and identity_id. Alias comparison is case-insensitive and shared between profiles and providers.

Class When Result
create no identity match and the alias is free written under its own alias
rename no identity match but the alias is taken (another account, a provider, or a case-only match) written as <alias>_2, … under every strategy; a different account is never overwritten. A renamed provider gets provider_id, name and env_key rewritten the same way provider rename does
unchanged identical content nothing
ambiguous email matches a local profile that has no account_id always skipped
conflict same identity or provider with different content, or a different config.toml decided by --strategy
--strategy Profile Provider config.toml
skip (default) keep local keep local keep local
overwrite replace (warning when the imported credentials cannot be shown to be newer; live handling see Review round 1) replace (and models.json) replace
merge imported wins only if its last_refresh is strictly newer than the profile and, for the current profile, the live auth.json; otherwise keep local treated as skip (see "Cut") add missing keys only, recursively; local values win; the result is validated through AppConfig

overwrite and merge show the plan and ask [y/N]. Without a TTY, under --json, or with --passphrase-stdin, they require --yes.

Apply sequence:

  1. Refuse to start if an earlier journal is still in-progress, and print the manual restore steps.
  2. Take lock_auth_transaction and reclassify under the lock. If the decisions differ from the previewed or confirmed plan, abort.
  3. Back up every file that will be replaced to import-backups/<UTC>/backup/ (0700/0600) and write journal.json.
  4. Write each file with atomic_write_private.
  5. On any error, roll back in reverse order (delete created files and directories, restore replaced files) and mark the journal rolled-back.

When an overwrite or merge hits the locally current profile, the live $CODEX_HOME/auth.json is backed up and written inside the same transaction only if it is the same account and provably older (see Review round 1). Otherwise current and the live auth.json are never touched, and the import only prints Source machine's current account: work → run codex-switch use work. --activate[=ALIAS] calls cmd_use after the commit (round 1: with its untracked-live confirmation); the default alias is the source machine's current account, after any rename.

Testing

All accounts, tokens, API keys and proxy passwords are mock. No real OpenAI account was used, and no Usage API or network call was made: the JWTs are unsigned fake x.<claims>.y strings.

Local quality gate on Linux x86_64 (rustc 1.99.1 stable):

  • cargo fmt --check: pass
  • cargo clippy --all-targets -- -D warnings: pass, 0 warnings
  • cargo test --all --no-fail-fast: 1034 passed, 2 failed. Both failures are pre-existing or environmental, not caused by this change:
    • usage::api::tests::usage_http_timing_includes_delayed_mock_response_body also fails 2 out of 2 times on the untouched dev @ 83ba366 on the same box when the full lib suite runs, and passes when run alone.
    • test_launch_passthrough::json_use_keeps_the_daemon_report_off_stdout failed once with Text file busy (os error 26) while writing its fake Codex executable, then passed 2 out of 2 reruns.
  • New tests/test_transfer.rs: 19 / 19 pass. New unit tests in src/transfer/*: 6 pass.
  • cargo audit: pass, 0 vulnerabilities (389 crates scanned)
  • MSRV cargo +1.88 build --locked: pass
  • bash -n scripts/install.sh: pass

Integration tests cover all 17 cases in plan §8: round trip with byte-equality and 0600/0700 modes; wrong passphrase with zero writes; ciphertext, created_at and salt tampering; KDF bounds checked fast; skip; overwrite including backup, live-file update and 0700 backup directories; merge newer and older plus config merge plus provider skip; alias collision under all three strategies, including a case-only collision; dry-run with no writes and no secrets in the output; injected mid-apply failure and rollback (a debug-only hook through CODEX_SWITCH_TEST_IMPORT_FAIL_AT); the v1 fixture, schema 2 refused before the passphrase, and a wrong format routed to the legacy path; plaintext confirmation; no secret bytes or their base64 forms in the bundle or the decoded ciphertext; unsafe keys; no-clobber export; legacy paths unchanged; exactly one JSON value on stdout. Two extra tests cover --activate and an idempotent re-import.

Manual end-to-end run with the release binary, simulating machines A and B with separate HOME, CODEX_HOME and CODEX_SWITCH_HOME (all mock data):

  • export, and a refused re-export without --force
  • dry-run
  • import, with every file byte-identical to A and current not written
  • re-import, where everything is unchanged
  • wrong passphrase
  • 4 tampered variants
  • skip, overwrite and merge against a conflicting machine
  • --plaintext with and without --yes, and a plaintext import

A raw-byte scan of the encrypted bundle found 0 hits for 9 secret strings, in both plain and base64 forms.

Not verified locally: macOS and Windows (left to CI); interactive TTY prompts (only the --passphrase-stdin and --yes paths are automated); a real crash or power loss in the middle of an apply (only process-level failure injection was tested).

Cut (plan §9 order)

  1. import --rollback <dir> is not implemented. An in-progress journal blocks the next import, and the manual restore steps are printed instead.
  2. export --profiles a,b filtering is not implemented. Export always includes every profile; --no-providers and --no-config are available.
  3. Provider models union under merge is not implemented. A provider conflict under merge is treated as skip and reported as such.
  4. The TUI entry is out of scope, as agreed.
  5. Smaller simplification: apply writes each file atomically in place, with a journal and a backup. There is no separate .import-staging-* directory followed by a directory rename.

Known risks

  • Single-use refresh tokens. Copying an account and then using it on both machines will log one of them out. This is mitigated only by warnings, as decided.
  • overwrite on the current profile replaces the live auth.json without the ensure_live_not_older guard — fixed in review round 1 (H1/H2).
  • Backups and journals under import-backups/ contain secrets (0700/0600) and are never pruned automatically.
  • The plaintext checksum is not tamper-proof (it is documented as a corruption check only). Since round 1 a plaintext import needs --yes or interactive confirmation (M3).
  • A crash between writes leaves a partial state plus an in-progress journal, and recovery is manual (see Cut 1); since round 1 the journal maps each replaced file to its backup and the message lists the pairs (M2). Separate files cannot be committed atomically.
  • Windows reserved names (CON, NUL, …) are classified as rename only on Windows builds. This path is not exercised by tests.
  • Decrypted payload strings are not zeroized after parsing; only the raw buffers, the passphrase and the keys are.

Review round 1

Follow-up commit 1925def on top of e7583b0 (no rebase, no force push). Every item below has a regression test in tests/test_transfer.rs (prefixed r1_, plus one case added to kdf_bounds_are_checked_before_derivation). All 11 affected tests were run against the unchanged e7583b0 code and fail there; all pass after the fix. The tests drive the real debug binary against isolated temp HOME / CODEX_HOME / CODEX_SWITCH_HOME directories. All credentials are mock (unsigned fake JWTs, fake refresh/access tokens, fake API keys); no network or Usage API is involved.

Review item Fix Tests (fail on e7583b0 → pass now)
H1 live auth.json was replaced without comparing it; merge could roll back a newer live token; overwrite without timestamps gave no warning; plan did not show live classify now reads live. For the current profile, merge writes only if the import is strictly newer (by last_refresh) than the profile and live; overwrite replaces live only if live is provably older (same refresh_token, or both stamps and live earlier); otherwise the item is skip with a warning: reason unless the new --replace-live flag is given. Overwrites that cannot be shown to be newer than the profile now always carry a warning (also with missing/equal stamps). Before writing, every profile and live write is re-checked under the auth transaction by the new profile::guard_imported_credentials (account identity always; the newer-token rule unless the user explicitly chose overwrite / --replace-live). Items expose replaces_live in JSON and the text plan ("also replaces the live auth.json"); it counts for needs_confirmation and is part of the locked decision fingerprint. The "Imports are create-only" doc comment was corrected r1_merge_never_replaces_newer_live_credentials, r1_overwrite_keeps_newer_live_unless_replace_live, r1_overwrite_without_timestamps_is_flagged, r1_plan_reports_live_replacement
H2 live of another (untracked) account was overwritten Live is replaced only when it belongs to the imported account (ensure_same_account_identity rule); otherwise only the profile is written and the reason says run codex-switch use <alias>. --activate now goes through cmd_use (untracked-live confirmation) and, without a TTY, refuses before writing anything when live belongs to no saved profile r1_live_of_another_account_is_never_overwritten
M1 same account under two aliases in one bundle → two writes to one target / two profiles Bundle profiles are grouped by (account_id, email); only the provably newest entry is written, the others are skip ("same account as … in this bundle"); with no provable winner the whole group is ambiguous. Also (L2) several local profiles of one account are now ambiguous, and a final check rejects any plan where two writes hit the same file (invalid_bundle) r1_duplicate_account_in_bundle_is_written_once (existing machine and empty machine)
M3 plaintext import needed no confirmation; provider overrides invisible Any write from an unencrypted bundle needs --yes or interactive confirmation (same as export --plaintext). Provider items show base_url and codex_config_overrides (count only, no values or keys); a written provider with overrides needs confirmation even from an encrypted bundle r1_plaintext_import_requires_confirmation, r1_provider_overrides_need_confirmation; existing tests 12 and 14 now pass --yes for plaintext imports (intended behavior change)
M2 crash recovery was unclear journal.json gains backups: [{path, backup}] (backup path relative to the journal dir; old journals still parse). The refusal message lists every backup -> original pair and every created file. import --dry-run, use, launch and export print a warning while an in-progress journal exists. After each atomic write the parent directory is fsynced (Unix), so journal and data reach disk in order r1_interrupted_import_is_recoverable (uses a new debug-only CODEX_SWITCH_TEST_IMPORT_ABORT_AT hook that calls abort()); plus a real SIGKILL E2E below
L1 KDF upper bound m ≤ 256 MiB, t ≤ 6 (checked before deriving) kdf_bounds_are_checked_before_derivation (new 1 GiB and t=10 cases)
L3 --json usage errors bundle-flag usage errors print {"ok":false,"code":"usage",…} and exit 2 r1_json_usage_error_is_json

Docs: docs/CHANGELOG.md entry rewritten in Chinese (the file is Chinese) with the new rules; docs/wiki/Command-Reference.md, Feature-Guide.md, Chinese-Guide.md and import --help updated. README.md / README_CN.md one-liners are still accurate and unchanged.

Local checks (Linux x86_64, rustc 1.99.1)

  • cargo fmt --check: pass; cargo clippy --all-targets --locked -- -D warnings: pass; cargo +1.88 build --locked (MSRV): pass
  • cargo test --all --locked --no-fail-fast: 1045 passed, 1 failed — usage::api::tests::usage_http_timing_includes_delayed_mock_response_body, the load-sensitive test the reviewer also saw fail on untouched dev; it passes 3/3 when run alone. test_transfer: 29/29.

End-to-end (release binary, mock data, unshare -rn, temp HOMEs)

Re-ran the reviewer's scenarios with target/release/codex-switch:

  • S1 merge with live newer than the bundle: skip, live keeps the newest token.
  • S1b overwrite: skip with a warning; with --replace-live, live is replaced and the old live token is in backup/live/auth.json.
  • S1c live provably older: replaced, the plan prints "also replaces the live auth.json", and the import is refused without --yes.
  • S2 no timestamps: flagged with a warning.
  • S3 untracked stranger live: untouched. --activate is refused and nothing is written.
  • S4 duplicate local profiles: ambiguous.
  • S5 duplicate account in the bundle: one write, with the newest token. Into an empty machine: one profile. With no provable winner: both ambiguous.
  • S6 plaintext with a codex_config provider: refused without --yes. The dry-run shows base_url and the override count, and leaks no secrets. An encrypted bundle with that provider also needs --yes.
  • S7 real SIGKILL between the profile write and the live write (hit on attempt 1): the journal lists the backup pairs. The next import prints them. Dry-run, use and export warn. Following the printed steps by hand restores the state, and import works again.
  • S8 1 GiB KDF: rejected in 5 ms.
  • S9 --json usage errors are JSON.
  • S10 wrong passphrase, tampered ciphertext, truncated and empty files, empty passphrase: distinct errors, and nothing written outside logs/.
  • S11 round trip: byte-identical, the re-import is all unchanged, and the logs contain no tokens.

Mock vs real / not covered

  • Mock: all accounts, tokens and provider keys; no real OpenAI login, no Usage API, no Codex launch (so whether a codex_config notify would run was not tested, only that it now needs confirmation and is shown).
  • Not run locally: macOS / Windows (CI only), interactive TTY prompts (only --yes / --passphrase-stdin / non-TTY paths are automated), real power loss (directory fsync is in place but not power-cut tested).
  • Not done this round (still follow-ups): import --rollback <dir>; L4 (sniffing reads the whole file and falls back to the legacy path); L5 (weak passphrase warning); L6 (Windows trailing-dot aliases); L7 (create_private_windows_temp for Windows bundle writes); L8 (unrelated Cargo.lock churn, not touched here). Codex itself does not take codex-switch's auth lock, so a Codex refresh racing the few milliseconds between the write-time re-check and the rename is still possible; the backup keeps the replaced file.

Add `codex-switch export [FILE]`, which writes every saved profile,
custom provider (API key and model catalog) and config.toml into one
.csbundle encrypted with AES-256-GCM under an Argon2id key (ring +
argon2), and teach `codex-switch import` to detect such bundles by
content. Bundle import is offline, validates every entry before writing,
classifies conflicts (skip/overwrite/merge, default skip), always renames
a different account under a taken alias, backs up replaced files with a
journal and rolls back on error. The legacy auth.json import is
unchanged; bundle-only flags on it are usage errors.

Both commands warn that refresh tokens are single-use.
…review round 1)

H1: when an import updates the current profile, the live auth.json is
compared too. Merge writes only when the imported credentials are provably
newer than both the profile and live; overwrite replaces live only when live
is provably older, otherwise the item is skipped unless --replace-live is
given. Missing/equal timestamps are flagged. Profile and live writes re-check
account identity and the newer-token rule at write time
(profile::guard_imported_credentials). The plan and JSON expose
replaces_live, which also requires confirmation and is part of the locked
decision fingerprint.

H2: live is only replaced when it belongs to the imported account; a live
login of another (untracked) account is left untouched with a hint, and
--activate goes through cmd_use's untracked-live confirmation.

M1: one account under several aliases in a bundle is written once, from the
provably newest entry (or not at all); several local profiles of one account
are ambiguous; no two writes may target the same file.

M3: importing an unencrypted bundle, or adding a provider with codex_config
overrides, needs --yes or interactive confirmation; the plan lists provider
base_url and override count.

M2: the journal maps each replaced path to its backup; the recovery message
lists the pairs; import --dry-run, use, launch and export warn about an
unfinished import; directory entries are fsynced after each write.

Also: KDF bounds tightened to m<=256 MiB, t<=6 (L1); --json bundle usage
errors are JSON (L3). CHANGELOG entry rewritten in Chinese.
@7678837-glitch
7678837-glitch merged commit 1c6f650 into dev Oct 19, 2026
4 checks passed
7678837-glitch added a commit that referenced this pull request Oct 19, 2026
- N4: `import --activate` refuses, on a terminal too and before any write,
  to switch over an untracked live auth.json of the same account that cannot
  be shown to be older than the target profile; re-checked before `use`.
- N3: an import that leaves any item skipped or ambiguous exits 1 with
  ok:false and code nothing_imported / partial_import, reports written /
  not_imported, and says to keep the bundle instead of deleting it.
  Identical duplicates of one account in a bundle count once.
- N1: debug-only CODEX_SWITCH_TEST_IMPORT_LIVE_BEFORE_APPLY hook and a test
  for the in-lock write-time re-check.
- N2: the non-TTY --activate refusal is asserted to leave the tree unchanged.
- N5: fsync the parent after creating each new directory (Unix).
- N6: dedicated LiveChangedDuringImport error for the live re-check.
- N7: import requires the same account_id and email on both sides before
  treating live (or a profile at write time) as the same account.
- CHANGELOG (Unreleased) and wiki: --yes behavior change, exit codes,
  --activate refusal.

Co-authored-by: 7678837-glitch <318980587+7678837-glitch@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant