Repository navigation
feat: encrypted export bundles and bundle import (CLI) - #119
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andmodels.jsonincluded) andconfig.tomlinto one passphrase-encrypted.csbundle.codex-switch import <bundle>detects a bundle by its content and restores it offline. It supports a dry-run preview andskip/overwrite/mergeconflict 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
--retire. Bothexportandimportprint a prominent stderr warning: refresh tokens are single-use, so if one account is used on two machines, one of them hitsrefresh_token_reusedand must runcodex-switch login <alias>.exportcommand;importsniffs 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 analiaswith a bundle, are reported as a clap usage error (exit 2). Clap cannot see file contents, so this check runs after the sniff and usesCli::command().error(ArgumentConflict, …).validate_managed_auth_value), the non-emptyaccount_idrequirement, the file credential-store gate and the identity rules still apply. Any invalid entry aborts the import before the first write.config.tomlis included by default (encrypted). The dry-run and the import flag[proxy]asmachine-specific, review.--no-config(and--no-providers) exclude it.ring0.17 (already in the lockfile) plusargon20.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 written0600(a file-only DACL on Windows), the parent directory's mode is left alone, and an existing file is refused unless--forceis 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. Onlyprofiles/<alias>/auth.json,providers/<alias>/provider.toml,providers/<alias>/models.jsonandconfig.tomlare accepted, and every alias goes throughvalidate_alias...,\,:, a leading/, any other file name, duplicate keys and kind/key mismatches are hard errors before any write.textkeeps the file bytes, so a create round-trips byte for byte. The header is parsed withdeny_unknown_fields. A minimal{format, schema_version}probe runs first, so a newer schema is refused before the passphrase prompt. Themigrate()hook exists, andtests/fixtures/export-v1.csbundleis 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.bakcopies andconfig.toml).Encryption
key_check = HMAC(check_key, "codex-switch-export key check v1").Errors carry distinct
--jsoncodevalues:codeunsupported_encryptionwrong_passphrasebundle_tamperedcodex-switch self-update")unsupported_bundle_versionformatmissing or wrongnot_a_bundleinvalid_bundleThe JSON error envelope gained an optional
codefield. Existing errors are unchanged because the field is omitted whenNone.Passphrase input: export prompts twice through
rpassword, and import prompts once. In non-interactive use,--passphrase-stdinreads the first line of stdin. The passphrase is never read from argv or an environment variable.--plaintextrequires a typed confirmation, or--yeswhen there is no TTY or--jsonis 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_idand lowercase email. Providers match on the same alias andidentity_id. Alias comparison is case-insensitive and shared between profiles and providers.<alias>_2, … under every strategy; a different account is never overwritten. A renamed provider getsprovider_id,nameandenv_keyrewritten the same wayprovider renamedoesaccount_idconfig.toml--strategy--strategyskip(default)overwritemodels.json)mergelast_refreshis strictly newer than the profile and, for the current profile, the liveauth.json; otherwise keep localAppConfigoverwriteandmergeshow the plan and ask[y/N]. Without a TTY, under--json, or with--passphrase-stdin, they require--yes.Apply sequence:
in-progress, and print the manual restore steps.lock_auth_transactionand reclassify under the lock. If the decisions differ from the previewed or confirmed plan, abort.import-backups/<UTC>/backup/(0700/0600) and writejournal.json.atomic_write_private.rolled-back.When an overwrite or merge hits the locally current profile, the live
$CODEX_HOME/auth.jsonis backed up and written inside the same transaction only if it is the same account and provably older (see Review round 1). Otherwisecurrentand the liveauth.jsonare never touched, and the import only printsSource machine's current account: work → run codex-switch use work.--activate[=ALIAS]callscmd_useafter 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>.ystrings.Local quality gate on Linux x86_64 (rustc 1.99.1 stable):
cargo fmt --check: passcargo clippy --all-targets -- -D warnings: pass, 0 warningscargo 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_bodyalso fails 2 out of 2 times on the untoucheddev@83ba366on the same box when the full lib suite runs, and passes when run alone.test_launch_passthrough::json_use_keeps_the_daemon_report_off_stdoutfailed once withText file busy (os error 26)while writing its fake Codex executable, then passed 2 out of 2 reruns.tests/test_transfer.rs: 19 / 19 pass. New unit tests insrc/transfer/*: 6 pass.cargo audit: pass, 0 vulnerabilities (389 crates scanned)cargo +1.88 build --locked: passbash -n scripts/install.sh: passIntegration tests cover all 17 cases in plan §8: round trip with byte-equality and 0600/0700 modes; wrong passphrase with zero writes; ciphertext,
created_atand 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 throughCODEX_SWITCH_TEST_IMPORT_FAIL_AT); the v1 fixture, schema 2 refused before the passphrase, and a wrongformatrouted 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--activateand an idempotent re-import.Manual end-to-end run with the release binary, simulating machines A and B with separate
HOME,CODEX_HOMEandCODEX_SWITCH_HOME(all mock data):--forcecurrentnot writtenunchangedskip,overwriteandmergeagainst a conflicting machine--plaintextwith and without--yes, and a plaintext importA 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-stdinand--yespaths 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)
import --rollback <dir>is not implemented. Anin-progressjournal blocks the next import, and the manual restore steps are printed instead.export --profiles a,bfiltering is not implemented. Export always includes every profile;--no-providersand--no-configare available.modelsunion undermergeis not implemented. A provider conflict undermergeis treated asskipand reported as such..import-staging-*directory followed by a directory rename.Known risks
— fixed in review round 1 (H1/H2).overwriteon the current profile replaces the liveauth.jsonwithout theensure_live_not_olderguardimport-backups/contain secrets (0700/0600) and are never pruned automatically.--yesor interactive confirmation (M3).in-progressjournal, 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.CON,NUL, …) are classified asrenameonly on Windows builds. This path is not exercised by tests.Review round 1
Follow-up commit
1925defon top ofe7583b0(no rebase, no force push). Every item below has a regression test intests/test_transfer.rs(prefixedr1_, plus one case added tokdf_bounds_are_checked_before_derivation). All 11 affected tests were run against the unchangede7583b0code and fail there; all pass after the fix. The tests drive the real debug binary against isolated tempHOME/CODEX_HOME/CODEX_SWITCH_HOMEdirectories. All credentials are mock (unsigned fake JWTs, fake refresh/access tokens, fake API keys); no network or Usage API is involved.auth.jsonwas replaced without comparing it; merge could roll back a newer live token; overwrite without timestamps gave no warning; plan did not show liveclassifynow reads live. For the current profile,mergewrites only if the import is strictly newer (bylast_refresh) than the profile and live;overwritereplaces live only if live is provably older (samerefresh_token, or both stamps and live earlier); otherwise the item isskipwith awarning:reason unless the new--replace-liveflag 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 newprofile::guard_imported_credentials(account identity always; the newer-token rule unless the user explicitly chose overwrite /--replace-live). Items exposereplaces_livein JSON and the text plan ("also replaces the live auth.json"); it counts forneeds_confirmationand is part of the locked decision fingerprint. The "Imports are create-only" doc comment was correctedr1_merge_never_replaces_newer_live_credentials,r1_overwrite_keeps_newer_live_unless_replace_live,r1_overwrite_without_timestamps_is_flagged,r1_plan_reports_live_replacementensure_same_account_identityrule); otherwise only the profile is written and the reason saysrun codex-switch use <alias>.--activatenow goes throughcmd_use(untracked-live confirmation) and, without a TTY, refuses before writing anything when live belongs to no saved profiler1_live_of_another_account_is_never_overwrittenaccount_id, email); only the provably newest entry is written, the others areskip("same account as … in this bundle"); with no provable winner the whole group isambiguous. Also (L2) several local profiles of one account are nowambiguous, 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)--yesor interactive confirmation (same asexport --plaintext). Provider items showbase_urlandcodex_config_overrides(count only, no values or keys); a written provider with overrides needs confirmation even from an encrypted bundler1_plaintext_import_requires_confirmation,r1_provider_overrides_need_confirmation; existing tests 12 and 14 now pass--yesfor plaintext imports (intended behavior change)journal.jsongainsbackups: [{path, backup}](backup path relative to the journal dir; old journals still parse). The refusal message lists everybackup -> originalpair and every created file.import --dry-run,use,launchandexportprint a warning while anin-progressjournal exists. After each atomic write the parent directory is fsynced (Unix), so journal and data reach disk in orderr1_interrupted_import_is_recoverable(uses a new debug-onlyCODEX_SWITCH_TEST_IMPORT_ABORT_AThook that callsabort()); plus a realSIGKILLE2E belowkdf_bounds_are_checked_before_derivation(new 1 GiB and t=10 cases)--jsonusage errors{"ok":false,"code":"usage",…}and exit 2r1_json_usage_error_is_jsonDocs:
docs/CHANGELOG.mdentry rewritten in Chinese (the file is Chinese) with the new rules;docs/wiki/Command-Reference.md,Feature-Guide.md,Chinese-Guide.mdandimport --helpupdated.README.md/README_CN.mdone-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): passcargo 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 untoucheddev; 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:skip, live keeps the newest token.skipwith a warning; with--replace-live, live is replaced and the old live token is inbackup/live/auth.json.--yes.--activateis refused and nothing is written.ambiguous.ambiguous.codex_configprovider: refused without--yes. The dry-run showsbase_urland the override count, and leaks no secrets. An encrypted bundle with that provider also needs--yes.SIGKILLbetween the profile write and the live write (hit on attempt 1): the journal lists the backup pairs. The next import prints them. Dry-run,useandexportwarn. Following the printed steps by hand restores the state, and import works again.--jsonusage errors are JSON.logs/.unchanged, and the logs contain no tokens.Mock vs real / not covered
codex_confignotifywould run was not tested, only that it now needs confirmation and is shown).--yes/--passphrase-stdin/ non-TTY paths are automated), real power loss (directory fsync is in place but not power-cut tested).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_tempfor Windows bundle writes); L8 (unrelatedCargo.lockchurn, 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.