Unofficial / empirically verified / version-sensitive
I wanted to run two independent Codex Desktop identities on Windows while keeping a single Microsoft Store installation:
OpenAI.Codex Microsoft Store package
│
├─ normal Store instance
│ └─ WORK
│
└─ isolated instance
├─ separate CODEX_HOME
└─ separate Chromium --user-data-dir
The goal was specifically to avoid:
- duplicating the Microsoft Store installation,
- copying Store binaries,
- cloning the MSIX package,
- changing global/user environment variables,
- modifying ACLs under
C:\Program Files\WindowsApps, - or duplicating Git working trees.
This configuration is now working in practice, including simultaneous execution, independent state, and persistence after a cold restart.
This is not an official multi-profile feature. It depends on observed Codex Desktop/MSIX behavior and could break after a future update.
The problem: launching ChatGPT.exe directly
The installed executable can be found dynamically:
$pkg = Get-AppxPackage -Name OpenAI.Codex
$exe = Join-Path $pkg.InstallLocation "app\ChatGPT.exe"
My original proof of concept launched that executable directly.
Later this stopped working and direct execution returned:
Access is denied
The executable itself was not damaged. The important difference was package context: launching the physical executable directly was not equivalent to launching it with the Microsoft Store/MSIX package identity.
So I would not recommend making this the permanent launcher:
Start-Process $exe
I would also avoid hardcoding a path like:
C:\Program Files\WindowsApps\OpenAI.Codex_<version>\...
because Store updates can change the package path.
Working solution: launch inside the package context
The package should first be resolved dynamically:
$pkg = Get-AppxPackage -Name OpenAI.Codex
if (-not $pkg) {
throw "OpenAI.Codex package was not found."
}
$exe = Join-Path $pkg.InstallLocation "app\ChatGPT.exe"
if (-not (Test-Path $exe)) {
throw "ChatGPT.exe was not found in the installed package."
}
The working launcher uses:
Invoke-CommandInDesktopPackage
For the isolated PERSONAL profile I use separate locations such as:
%LOCALAPPDATA%\OpenAI-Profiles\personal\codex-home
%LOCALAPPDATA%\OpenAI-Profiles\personal\user-data
These isolate two different kinds of state:
CODEX_HOME→ Codex persistent state--user-data-dir→ Chromium/Electron profile state
One subtle issue was that setting:
$env:CODEX_HOME = $codexHome
in the parent PowerShell process was not sufficient in my tested package-activation path.
The reliable solution was a small .cmd wrapper created dynamically:
@echo off
set "CODEX_HOME=<personal-codex-home>"
start "" /b "<resolved-ChatGPT.exe>" --user-data-dir="<personal-user-data>"
and then launch that wrapper inside the package:
Invoke-CommandInDesktopPackage `
-PackageFamilyName $pkg.PackageFamilyName `
-AppId "App" `
-Command "$env:SystemRoot\System32\cmd.exe" `
-Args "/d /c `"$wrapper`"" `
-PreventBreakaway
In my testing, -PreventBreakaway was important for retaining the required package context.
The resulting combination was:
package identity OK
--user-data-dir OK
CODEX_HOME OK
simultaneous run OK
The standard Store instance and isolated instance can run concurrently and maintain separate state.
Complete reusable launcher
Here is the complete generic launcher pattern:
$profileRoot = Join-Path $env:LOCALAPPDATA "OpenAI-Profiles\personal"
$codexHome = Join-Path $profileRoot "codex-home"
$userData = Join-Path $profileRoot "user-data"
$wrapper = Join-Path $profileRoot "launch-personal.cmd"
New-Item -ItemType Directory -Force -Path $profileRoot | Out-Null
New-Item -ItemType Directory -Force -Path $codexHome | Out-Null
New-Item -ItemType Directory -Force -Path $userData | Out-Null
$pkg = Get-AppxPackage -Name OpenAI.Codex
if (-not $pkg) {
throw "OpenAI.Codex Microsoft Store package was not found."
}
$exe = Join-Path $pkg.InstallLocation "app\ChatGPT.exe"
if (-not (Test-Path $exe)) {
throw "ChatGPT.exe was not found at the expected package location."
}
@"
@echo off
set "CODEX_HOME=$codexHome"
start "" /b "$exe" --user-data-dir="$userData"
"@ | Set-Content -Path $wrapper -Encoding ASCII
Invoke-CommandInDesktopPackage `
-PackageFamilyName $pkg.PackageFamilyName `
-AppId "App" `
-Command "$env:SystemRoot\System32\cmd.exe" `
-Args "/d /c `"$wrapper`"" `
-PreventBreakaway
The effective process chain is approximately:
PowerShell
│
└─ Invoke-CommandInDesktopPackage
│
└─ cmd.exe
│
├─ CODEX_HOME=<PERSONAL path>
│
└─ ChatGPT.exe
└─ --user-data-dir=<PERSONAL Chromium path>
The wrapper is intentional: it ensures that CODEX_HOME is established in the process chain that runs inside the package context.
One caveat: this particular wrapper was validated using -Encoding ASCII. If your Windows paths contain characters that cannot be represented in ASCII, test the encoding explicitly.
Migrating an existing Codex profile
I also migrated an existing Codex profile into the isolated CODEX_HOME.
I would strongly recommend not copying the complete directory blindly.
The migration separated persistent user state from regenerable runtime state.
Persistent data migrated where applicable included semantic categories such as:
sessions
archived_sessions
attachments
memories
rules
history
project/thread state
Runtime/bootstrap data was intentionally not migrated, including:
auth.json
.sandbox
.sandbox-bin
.sandbox-secrets
.sandbox_migration
transient locks/guards
runtime/plugin binaries
auth.json was recreated through a fresh login.
The sandbox was also allowed to reprovision normally in the new profile, including its one-time Windows/UAC setup.
That produced the desired separation:
persistent user state -> migrate selectively
authentication -> recreate
sandbox/runtime state -> reprovision
Some persistent state contained filesystem references to the previous CODEX_HOME.
Those were rewritten only where they were actual structural path fields, for example:
- rollout paths,
- project roots,
- sandbox-policy paths,
- attachment metadata,
- configuration/global state.
Historical conversational text mentioning an old path was deliberately not rewritten.
Also note that Codex state formats and SQLite schemas are internal implementation details. Any migration touching them should be treated as version-sensitive.
How I validated the isolation
I did not consider the migration successful merely because the UI looked right.
Before launching the isolated profile I took a filesystem snapshot of the source profile containing:
relative path
size
SHA-256
After launching the isolated profile, comparison against that baseline showed:
added = 0
deleted = 0
changed = 0
SQLite databases involved in the migrated state were checked using:
PRAGMA quick_check;
with:
ok
I also checked expected thread/project/history counts and audited structural path fields.
Result:
structural references to old CODEX_HOME = 0
Then I created a completely new Codex thread from the isolated instance.
It:
- appeared only in the isolated profile,
- stored its rollout data below the isolated
CODEX_HOME, - and increased that profile’s thread count by one.
Finally I performed a full cold launch.
After restart:
- login persisted,
- sandbox provisioning did not repeat,
- migrated projects and threads remained accessible,
- the new test thread survived,
- and its persisted history remained present.
The original profile was snapshotted again afterwards and remained unchanged.
So the practical validation was:
migration succeeds
+
new isolated profile can write
+
state survives restart
+
original profile remains unchanged
Further lessons: launchers, taskbar identity, and startup races
After validating state isolation, I refined the everyday launch experience. The following are observations from that setup, not supported Codex features or a complete implementation of the advanced launcher. In particular, taskbar timing was tested with Start11; other shells and future application versions may behave differently.
The final arrangement kept WORK at its original Codex home, represented generically as %USERPROFILE%\.codex, with the standard Store Chromium profile and no custom --user-data-dir. PERSONAL used the separate Codex home and Chromium profile described above. Keeping WORK in place is compatible with selectively copying persistent state into PERSONAL; the original directory was not moved.
Resolve changing paths at launch
The Store package updated during the investigation. The later launcher selected the newest returned package version dynamically:
$pkg = Get-AppxPackage -Name OpenAI.Codex |
Sort-Object { [version]$_.Version } -Descending |
Select-Object -First 1
The missing-package and missing-executable checks from the minimal launcher still apply. For shortcuts, resolve the Desktop rather than assuming it is directly beneath the user profile:
[Environment]::GetFolderPath("Desktop")
Set the environment inside the packaged child; hide the outer console
The .cmd wrapper above was the validated minimal approach. The later launcher used a temporary VBS wrapper inside the package context to establish CODEX_HOME in the child process. Its essential pattern was:
Set sh = CreateObject("WScript.Shell")
sh.Environment("PROCESS")("CODEX_HOME") = "<desired path>"
cmd = Chr(34) & "<resolved ChatGPT.exe>" & Chr(34) & " <profile arguments>"
sh.Run cmd, 1, False
These angle-bracket values are placeholders. The temporary wrapper deleted itself after launch. Package activation still used Invoke-CommandInDesktopPackage with -PreventBreakaway.
A separate, permanent outer VBS launcher eliminated a console flash that persisted when the shortcut targeted PowerShell directly, even with -WindowStyle Hidden:
shortcut -> wscript.exe -> outer VBS
-> powershell.exe -WindowStyle Hidden
-> serialization wrapper -> core launcher
The outer VBS used WScript.Shell.Run(..., 0, False). The outer wrapper hides the console; the inner wrapper establishes the packaged child’s environment.
Shortcut identity and running-window identity must agree
Separate custom AppUserModelIDs were needed for reliable grouping under the two custom pins. Generic example values are:
PERSONAL: Example.Codex.PERSONAL
WORK: Example.Codex.WORK
These are local grouping labels, not account IDs. The essential relationship was:
pinned shortcut System.AppUserModel.ID
==
running window System.AppUserModel.ID
Using the package GUI AUMID (<PackageFamilyName>!App) on the custom WORK shortcut did not reliably achieve the desired grouping. The manifest’s command-runner entry was not the GUI application entry. Passing --app-user-model-id also did not prevent initial grouping under the default package identity in this tested setup.
There was a persistence trap when editing .lnk files: IPersistFile.Load(..., 0) allowed an in-memory property update to appear successful without persisting it. Opening read/write (STGM_READWRITE = 0x00000002), setting System.AppUserModel.ID, calling Commit() and IPersistFile.Save(), and then reloading the shortcut from disk verified the actual saved result.
For the running window, SHGetPropertyStoreForWindow(hwnd) allowed the same property to be applied to the HWND. Applying it late could take tens of seconds to regroup in the tested taskbar, so the launcher applied it during startup.
Watch the whole Electron startup, and serialize competing launchers
The first ChatGPT.exe window was not necessarily the final main window. The successful hook recorded already-running PIDs, armed SetWinEventHook before launch, watched EVENT_OBJECT_CREATE and EVENT_OBJECT_SHOW, ignored pre-existing PIDs, and applied the profile identity to new top-level ChatGPT windows throughout bootstrap.
This initially introduced a cross-profile race: if WORK’s hook was still active when PERSONAL started, WORK could label PERSONAL’s new window as WORK. Querying Win32_Process.CommandLine through WMI could distinguish the profile arguments, but the result arrived too late or inconsistently for immediate grouping. A retry queue still caused late regrouping.
The tested solution was a shared named mutex around both launchers’ startup-hook phase, for example Local\Example.Codex.ProfileLaunch. Only one participating launcher could run that phase at a time. The wrapper acquired the mutex, invoked its core launcher, and released it after window identity settled. Both applications could continue running concurrently afterward.
Scope of this reasoning: the mutex coordinates launchers that acquire it. It does not establish that every unrelated app launch is serialized; the recorded successful test used the two cooperating launchers.
A fixed three-second hook was too short: PERSONAL’s final window could appear after the hook exited. The tested replacement waited for a non-zero main window handle and a stable PID + HWND, with these settings:
| Setting | PERSONAL | WORK |
|---|---|---|
| Minimum hook duration | 5 seconds | 2.5 seconds |
| Stable main-window interval | 750 ms | 750 ms |
| Maximum safety timeout | 20 seconds | 20 seconds |
These are tested settings, not universal timing guarantees. The launcher reapplied identity to the final settled window before releasing the mutex. Near-simultaneous WORK-then-PERSONAL startup succeeded with this arrangement.
Give the shortcut and running window matching visual cues
The traditional EXE IDR_MAINFRAME icon looked optically smaller in the taskbar. The MSIX Square44x44Logo.targetsize-* assets, particularly the altform-unplated PNGs, worked better in the tested dark taskbar.
In the useful source images, the artwork was white RGB with antialiasing stored in alpha. Recoloring RGB while preserving alpha produced clean results. In GIMP, locking alpha and filling with the chosen foreground color was sufficient. Distinct colors such as blue for PERSONAL and gold for WORK made the profiles easier to recognize.
Each ICO frame needed its real pixel dimensions: a 32-by-32 image should be a 32-by-32 frame, not a small drawing on a 256-by-256 canvas. Native target-size artwork worked better than rescaling one large master. If overwriting an ICO appeared to have no effect, exporting to a new filename and updating the shortcut helped distinguish icon-cache behavior from a malformed ICO.
A shortcut’s icon did not automatically change the running window’s icon. Applying WM_SETICON (0x0080) for both ICON_SMALL and ICON_BIG changed the taskbar/Alt+Tab icon in testing. SetWindowText supplied the labels PERSONAL and WORK. The launcher applied the grouping ID, title, and icon during startup and again to the final settled main window.
The recorded final result was distinct pinned and running icons, matching PERSONAL/WORK labels in the taskbar and Alt+Tab, and matching shortcut/window AppUserModelIDs, alongside the separate state locations already validated above. Visual labels are useful confirmation cues; state isolation still needs the filesystem and persistence checks described earlier.
Keep experiments reversible and validate scripts before launch
Customization stayed outside the Store installation. There was no need to take ownership of WindowsApps, change its ACLs, modify package assets in place, or clone the installation. Backups before each significant launcher experiment were useful because some changes parsed correctly but behaved worse.
One PowerShell parsing pitfall was a colon immediately after an interpolated variable. Using a generic example variable:
# Ambiguous/invalid variable reference:
"$profileLabel: something"
# Delimit the variable explicitly:
"${profileLabel}: something"
Patched scripts were checked before use with:
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
$path,
[ref]$tokens,
[ref]$errors
) | Out-Null
Parse errors meant restoring the backup and aborting that attempt. Behavioral checks still mattered after parsing: correct grouping at first appearance, both launch orders as applicable, final-window identity, and continued profile separation.
Approaches I would avoid
Based on this investigation, I would not recommend:
- changing ACLs recursively under
C:\Program Files\WindowsApps, - using
takeown /ras a workaround, - using
icacls /reset /t, - cloning the MSIX package,
- copying Store executables elsewhere,
- hardcoding the Store package version/path,
- or blindly copying the entire existing Codex home.
None of those were required for the working setup.
Limitations
This technique is empirically verified, not an official product feature.
In particular:
- multiple isolated Codex Desktop profiles may not be a supported scenario;
- Codex state/database formats can change;
AppId "App"may change;--user-data-dirbehavior may change;- package activation behavior may change;
- profile migration is inherently version-sensitive.
After a significant Store/Codex update I would therefore re-check:
package-context launch
CODEX_HOME isolation
Chromium state isolation
simultaneous execution
write location
cold-launch persistence
The useful architectural result is quite simple:
one Microsoft Store package
+
shared application binaries
+
package-context launch
+
separate CODEX_HOME
+
separate Chromium user-data
=
two isolated Codex Desktop identities
I am posting this because I could not find this exact combination documented elsewhere. If anyone has found a cleaner supported mechanism for running multiple Codex Desktop identities from one Store installation, or knows of package/runtime assumptions here that are likely to change, I would be interested in comparing notes.
Authorship/privacy note: this write-up was prepared with AI assistance from a user-authorized, de-identified troubleshooting and migration session. No account IDs, workspace IDs, usernames, private project names, session IDs, or personal filesystem paths are included.