Run one macOS app as several truly separate apps: separate accounts, separate data, separate name and icon in the Dock, Finder, Spotlight and the Cmd-Tab switcher.
Doppel was built for the ChatGPT desktop app, which supports only one account per install. It clones the app you already have into additional instances ("ChatGPT Personal", "ChatGPT Work", each with its own tinted icon), points each instance at its own data directories, and re-signs the clone locally. No vendor code ships with this project; every clone is generated on your machine from your own installed copy. When an update is available, Doppel downloads it only from the official OpenAI appcast and verifies the vendor signature before installation.
Claude desktop instances use the same launcher, signing, rebuild and rollback
mechanisms. In New Instance, select Claude, then choose a name and colour.
Each instance gets its own desktop profile and Claude Code configuration directory.
Claude tints colour the tile while preserving the pale star, with light colours shaded for contrast.
The CLI selects Claude with doppel --app claude create --name "Claude Personal" --tint F28C28;
use the same --app claude prefix for list, launch, rebuild, edit and remove.
Code history is local to each desktop profile. After signing in and opening Code,
open the instance window from the panel and choose
Import… under Maintenance to copy sessions from the same account
and workspace in the original Claude app. This keeps new sessions already in the
instance, copies no browser login storage or scheduled-task settings, and leaves
the originals in place. Missing transcripts are reported; missing working folders
must be restored before continuing those sessions. The CLI equivalent is
doppel --app claude import-sessions "Claude Personal"; add --dry-run to preview
the counts without changing anything.
Working folders and worktree leases stay with the original profile. If Claude
creates a new worktree when you resume an imported conversation, check the old
folder for uncommitted work before continuing.
Google sign-in temporarily routes claude:// to the initiating instance. Finish
one sign-in before starting another or rebuilding instances. Doppel restores the original Claude handler
after the callback, cancellation, timeout or normal quit. Claude's callback and
nonce validation remain in place.
Update the original /Applications/Claude.app, then reopen or rebuild its managed
instances. Updates inside clones are disabled so they cannot replace their own
profile routing. ChatGPT's coordinated update feed, Browser assignment, Creator
Micro assignment and iPhone Remote Access remain ChatGPT-specific. Claude's own
restrictions on browser pairing with relocated profiles remain in effect.
A wrapper that starts the same app with a different profile works, but macOS treats the running process as the original app: same icon, same Dock identity, and only one of them can pin to the Dock meaningfully. A Doppel instance is a real bundle with its own bundle identifier, so macOS treats it as a genuinely different app. The engine coordinator authorizes the original launcher to open the profile, holding its lock until that process becomes the running app. It does not reopen the bundle as a second instance, so the running app stays on its pinned Dock icon. Automatic startup repair also preserves the app directory used by the Dock. Rebuild an existing instance once after updating Doppel to install the updated routing.
Managed instances cannot safely run ChatGPT's own Sparkle installer: their local identity cannot match OpenAI's signing Team ID, and an in-place vendor update would erase Doppel's launcher and account routing. Doppel disables that updater inside every instance and checks OpenAI's production appcast itself.
The menu app checks shortly after launch and every six hours. A new build posts one macOS notification and shows a banner in the panel with the same two-stage flow as the standard updater: download or wait, then restart and update or wait. While Doppel works the banner shows what it is doing, and a notification reports the result or the failure. The download is resumable, its URL has to match OpenAI's own release path, and nothing closes until the staged app has the expected bundle ID, build number and a signature satisfying the vendor app's own designated requirement: Apple-anchored, Developer ID, and OpenAI's Team ID. An older build is refused outright, so a genuine release cannot be replayed to walk the primary backwards. See the threat model for what these checks do and do not cover.
The one build that is not newer and still worth applying is the one already
installed. ChatGPT's own updater can replace the primary on its own, including
between Doppel staging a build and the user accepting the restart, and it never
touches the managed instances. Doppel reports that as its own state rather than
as being up to date: update check counts the instances built from a different
build, and update apply reconciles them by rebuilding from the app already in
place. Nothing is downloaded, the primary is left alone and keeps running, and
only the instances that are actually behind are closed and rebuilt. A reconcile
with nothing to rebuild closes nothing at all.
A reconcile only ever moves an instance forward. An instance built from a build
newer than the installed primary is counted and reported separately and is
never rebuilt by an update: doing that would hand a profile a later version has
already migrated back to an earlier one. doppel rebuild <name> still does it
for someone who means to.
No older build is ever installed either way. Naming one explicitly is refused; a feed that has fallen behind the installed primary, which happens while OpenAI rolls a release back, reconciles against what is installed instead of trying to apply what the feed says.
On restart Doppel records which instances were open, closes all managed apps, atomically replaces the untouched primary while keeping a rollback, rebuilds every installed instance concurrently, verifies the source build plus each instance's pinned signature, and reopens only the apps that were previously running. APFS copy-on-write clones avoid copying the unchanged parts of the 1+ GB vendor bundle once per instance. A failed download changes nothing; a failed install restores the previous primary; and a partial rebuild keeps the restart manifest and reports the exact instance. If official ChatGPT was holding an unassigned managed profile, which happens when it is opened on the vendor's default data root, that profile comes back through its own clone rather than through official ChatGPT.
Updates do not accumulate on disk. Once one succeeds, updates/ keeps the
newest download, the newest two primary rollbacks (always including the one for
the installed build) and the one prepared build still waiting to be installed;
see the rollback notes under prune for the details and the knob.
The profile assigned the built-in browser is running through the untouched
official app rather than through a clone, so the restart manifest records it as
that profile rather than as the bare primary. Doppel quits it with everything
else and reopens it the way it was started, on the same Electron root and
CODEX_HOME and with the vendor's own updater still disabled, after the clones
are back. It still refuses before taking the lock or closing anything when the
official window belongs to a managed profile other than the assigned one, when
it belongs to a profile Doppel does not manage at all, or when more than one
official process is open. Those are shapes it cannot put back exactly as it
found them, and the refusal names the window to quit. A reconcile never closes
the official window, so it never asks.
Doppel 2.1.0 is available on the stable release page. Download the ZIP, quit Doppel and install its Doppel.app in place of your existing menu app. Your profile data and remote connections are retained. Existing installations can also use Check for Updates. Choose Claude in New Instance to add a Claude desktop profile.
Open an instance from the menu bar panel and choose Open… under Remote Access. Choose an assigned private Mac address, save the network, and sign in to the intended ChatGPT account using the temporary device code. Confirm the displayed email and workspace before choosing Enable Remote Access. Each profile gets its own stable port, phone key and separately authenticated remote home. Existing desktop credentials are not copied.
On the phone, open Codex → Settings → Add connection → SSH. Enter the supplied
host, port and username, choose private-key authentication, and paste the key from
Copy Private Key. The exported PKCS#8 PEM begins -----BEGIN PRIVATE KEY-----;
replace the entire key field when updating it. Check the host-key fingerprint. Choose Connection Saved
in Doppel when finished; Doppel restores the previous clipboard only if it still
contains the copied key. Repeat for another profile without changing the phone's
ChatGPT login.
A LAN address works only while the phone can reach that LAN. For Meshnet, enable it on both devices and allow the phone's incoming remote connections on the Mac. Select the Mac's Meshnet address in Doppel. See NordVPN's macOS setup and remote-access permissions. Doppel does not install a VPN or configure internet routing.
Ready on this Mac confirms the local listener and live identity, not a successful phone connection. Keep the Mac awake and the user logged in. The listener continues when the menu app quits and resumes after login when its selected private address is available. Disabling access revokes the phone key and stops only that profile's listener and remote daemon; its history is retained.
The phone key permits execution with the Mac user's filesystem privileges. Account separation is not an operating-system sandbox. New remote tasks are supported; desktop history, Browser, Computer Use and cloud synchronization are outside 2.0. Upgrading from 1.x enables no listeners or background jobs.
The CLI uses the same setup and lifecycle:
doppel remote status [profile] --json
doppel remote configure <profile> --bind-address <private-ip> [--host <hostname>]
doppel remote login <profile>
doppel remote enable <profile>
doppel remote disable <profile>
doppel remote replace-key <profile>
doppel remote update-runtime <profile>
doppel remote export-key <profile>Ordinary results are versioned JSON. Login emits temporary device-code and
completion events. Only export-key returns private key material. Disable an
enabled connection before replacing its key or updating its runtime.
The first launch on a Mac with no instances opens a short setup guide: it finds ChatGPT, sets up secure signing, creates a first instance and walks through its permissions. After that the menu bar panel covers the everyday flows. Its + button asks for a name and a colour and derives everything else:
The CLI drives the same engine:
bin/doppel create --name "ChatGPT Personal" \
--bundle-id com.openai.codex.personal \
--scheme codex-personal \
--profile-root "$HOME/Library/Application Support/ChatGPT Personal" \
--codex-home "$HOME/.codex-personal" \
--tint F28C28
bin/doppel list
bin/doppel launch "ChatGPT Personal"
bin/doppel browser assign "ChatGPT Personal" # give this profile the built-in browser
bin/doppel browser status
bin/doppel browser release
bin/doppel micro assign "ChatGPT Personal" # this clone answers the Creator Micro pad
bin/doppel micro status
bin/doppel micro channel "ChatGPT Personal" 3 # this clone uses Bluetooth channel 3
bin/doppel micro claude 2 # Claude Code uses channel 2
bin/doppel rebuild "ChatGPT Personal"
bin/doppel native-tools status
bin/doppel native-tools repair
bin/doppel native-tools claim-browser "ChatGPT Personal"
bin/doppel native-tools claim-browser --browser Edge "ChatGPT Work"
bin/doppel update check
bin/doppel update prepare
bin/doppel update apply # also rebuilds instances the
# vendor updater left behind
bin/doppel update verify
bin/doppel edit "ChatGPT Personal" --rename "ChatGPT Home" --tint 3B82F6
bin/doppel remove "ChatGPT Personal" # keeps the account data
bin/doppel remove "ChatGPT Personal" --purge-data # data to the Trash too
bin/doppel prune # drop old rollback copies
bin/doppel prune "ChatGPT Personal" # just this instance'sSettings › Codex Micro shows the pad with who holds each channel. Its Answers the pad menu assigns the keypad to a Doppel instance and shows the current selection. Choosing Nobody stops managed clones from using it, including when the assigned instance was removed. Doppel automatically restarts running previously assigned and newly selected instances to apply a change; closed instances stay closed. Save unfinished drafts before changing the assignment. Selecting the current owner leaves it running. Official ChatGPT also uses the keypad whenever it is running.
Each Bluetooth channel of the pad shows up as its own device, so instances can
share the pad by channel: doppel micro channel "ChatGPT Veridue" 1 and
doppel micro channel "ChatGPT Personal" 3 give each clone its own, leaving
channel 2 free for another app such as a Claude Code plugin. Switching is then a
press of the pad's channel button, with every app left running. A clone with a
channel uses the pad even when it is not the assigned instance, USB still
reaches every clone that uses the pad, and any in place of the number takes the
channel away. Channels are read when an instance starts, and doppel micro channel lists them (--porcelain lists them by slug for scripts). The three
tiles under Bluetooth channels in Settings do the same with a click. The assigned instance without a channel still listens on
every channel.
doppel micro claude 2, or choosing Claude Code for a channel in
Settings › Codex Micro, sets up Claude Code on a channel of its own. It lists
what it will change and waits for a yes: adding the codex-micro marketplace (currently
thomast8/codex-micro-for-claude-code, which has the channel setting),
installing the plugin, turning on CLAUDE_CODE_ENABLE_FUNCTION_HOOKS for the
Claude app's Code tab, and setting the channel in the plugin's config.toml. The
plugin needs Claude Code 2.1.303 or newer, and new Claude Code sessions pick it
up.
rebuild opens the rebuilt instance when it succeeds, even if the instance was
closed before the command began. Set DOPPEL_RELAUNCH=0 for maintenance or QA
that should rebuild without opening the app. The instance window labels this action
Rebuild and reopen.
If a current OpenAI release validates only inside its notarized disk image and
the byte-identical /Applications/ChatGPT.app fails the older standalone
strict-signature check, Doppel can rebuild from the exact-build verified image
preserved at updates/Official/<build>/ChatGPT.dmg. It mounts that image
read-only for the rebuild, applies the same bundle ID, build, Team ID and strict
nested-signature checks, and unmounts it before reopening the instance.
edit renames an instance, changes its icon colour, or both, and reopens the
instance afterwards if it was running, so the change is visible immediately.
Identity and data stay put — the bundle identifier, URL scheme and data
directories are fixed when an instance is created — so a rename never separates
an account from its chats. Instances can be referred to by their current name at
any time; a name that would end up sharing another instance's identifier is
refused, because one of the two would then be unreachable.
remove never erases anything: the app bundle goes to the Trash, the instance
definition is preserved under state/Removed/ so the removal can be undone, and
account data (profile, CODEX_HOME, and the Electron cache that sits beside
the profile under ~/Library/Caches) is left in place unless --purge-data is
given — and even then it is moved to the Trash, not deleted. The cache is
derived from the profile path rather than recorded, so an instance whose profile
lives outside Application Support simply has none to move.
--purge-data also refuses to touch anything that is not clearly the
instance's own: a home directory or one of its standard folders, a path outside
the home directory, data another instance is using, and the directories the
primary app and the Codex CLI use by default, including their caches. An
instance can legitimately be pointed at those to adopt an existing account,
which is exactly why removing it
must not take them with it. The refusal happens before anything is moved, so a
refused purge leaves the instance intact; remove it without the flag and delete
what you actually want gone yourself.
Every rebuild keeps the previous bundle as a rollback, and a vendor update
rebuilds on its own, so prune exists to drop the ones you no longer need. It
keeps the most recent rollback per instance (DOPPEL_KEEP_BACKUPS to change
that) and clears any staging left by a build that died. Rebuilds prune as they
go, so this is only needed to reclaim what earlier versions left behind.
Stored rollbacks are deliberately kept out of normal macOS app discovery. For
locally signed instances, Doppel moves the whole Contents directory aside
without editing it, so macOS sees a plain folder rather than a damaged app;
restoration moves it back before verification.
The untouched vendor-primary rollback stays whole under a hidden .Backups
directory because macOS App Management can prohibit changes inside vendor code.
Update storage is bounded the same way. After a successful update prepare or
update apply, and on a whole-machine doppel prune, updates/ keeps only the
newest downloaded ChatGPT-<build>.zip, the newest two primary rollbacks under
.Backups (DOPPEL_KEEP_UPDATE_BACKUPS to change that), and the one prepared
build that is still newer than the installed app. A rollback made from the
installed build is never removed, even when it is older than the limit would
allow, so stepping the primary back always has something to restore. A failed or
interrupted instance build removes its own staging copy, and prune and
remove sweep any left by one that was killed, skipping an instance whose
rebuild lock is held by a live process. prune <name> leaves updates/ alone.
native-tools repair applies those reversible migrations to rollbacks created
by older Doppel versions; it does not change active apps or account data.
create clones the primary app, tints its real icon with the colour you chose,
installs the instance into ~/Applications, and stores the instance definition in
~/Library/Application Support/Doppel/instances/.
Doppel treats an account profile and the process that opens it as separate
things. By default a profile uses its own locally signed Doppel app, and current
ChatGPT builds serve the built-in browser there too. That works only because of
how ChatGPT happens to launch its browser helper today, so one profile at a time
can still use the untouched /Applications/ChatGPT.app as its engine, keeping
the OpenAI signature chain intact if a vendor update closes that gap again:
bin/doppel browser assign "ChatGPT Personal"
bin/doppel launch "ChatGPT Personal"Assignment does not close an app by default. If a conflicting engine is open,
the app offers Restart and Assign with a warning about unfinished drafts; the
equivalent explicit CLI form is browser assign --quit-running <name>. Other
Doppel clones stay open. When the untouched official app is already using the
target managed profile, Doppel adopts that exact PID and start identity without
restarting it. Opening the assigned profile's branded app directly from Finder
or the Dock routes through that same official-engine launch path; it cannot
silently fall back to the locally signed engine. A stopped assignment refuses an
older or modified clone until it has been rebuilt with the current signed router.
Direct fallback launches use the same lock and a private one-shot authorization,
so assignment cannot cross the moment a clone adopts its Electron profile.
CODEX_HOME, browser data and account state still come from the
selected Doppel profile. The Dock/window identity is ordinary ChatGPT while that
engine is active because changing the vendor bundle would invalidate its
signature. browser release returns the profile to its normal Doppel engine and
also refuses while the official engine is running. Engine changes, launches,
rebuilds, removals and update installation share one lock, so two concurrent
Doppel commands cannot cross those checks. If the coordinator is interrupted,
it keeps that lock until the isolated critical worker has finished, so a launch
or filesystem operation cannot continue after another command enters. The lock
lease covers the worker's process group, so a supervisor crash cannot make an
active operation look stale. The
official engine is started from a clean environment containing only ordinary
macOS account values, the SSH agent socket when present, and its intended profile
inputs. Inherited Codex sandbox/thread variables are never forwarded. Its own
Sparkle updater is disabled for this session; Doppel updates every engine together,
and release first verifies that the fallback clone is on the matching build.
Doppel records the official process PID, start identity and build after launch
or adoption. Status compares that stored assignment with the live official
process: a normal Dock launch using a different managed profile is shown as an
assignment mismatch rather than being reported as the stored owner.
macOS grants privacy permissions to each app identity separately. A new Doppel instance therefore does not inherit access from ChatGPT or from another managed instance. Each instance's window lists the status of Microphone, Camera, Accessibility, Screen & System Audio Recording, and Notifications, with one button per row. Open Settings opens that exact System Settings category when access was denied or is already granted. If the app has not asked yet, Allow first runs Apple's native request as that one managed instance. That request is what registers an otherwise absent app in the Microphone, Camera, Screen Recording, or Accessibility list. Doppel then refreshes the status; it never requests unrelated permissions at the same time. When a profile is assigned Built-in Browser, the section is labelled Privacy permissions for the Doppel engine: official ChatGPT has its own macOS permission identity and these grants apply only after the profile returns to its clone.
Camera and microphone are optional until that account uses the relevant feature,
so Not requested is informational and never raises a warning. The public
Accessibility and Screen Recording checks expose only a boolean and can lag or
disagree with the visible System Settings row; Doppel labels an inconclusive
result Check in Settings instead of claiming the permission is missing. Only an explicit
denial, restriction, or outdated checker adds a dot to the menu-bar icon
and marks the instance's row in the panel.
Permissions that macOS grants only in the context of a specific action or target—such as Files and Folders, Calendar, Reminders, Location, and Apple Events—continue to be requested by ChatGPT when that feature is used. Doppel does not manufacture broad requests for capabilities an account may never use. Doppel never resets or writes the macOS privacy database itself; permissions remain under your control in System Settings. The menu app checks through Launch Services so macOS attributes the probe to the managed bundle rather than to Doppel itself, and checks again every five minutes and after a rebuild. Managed apps no longer raise a broad permission assistant at launch. Optional access is requested either by ChatGPT when a feature needs it or by selecting that one row in Doppel.
Settings › Integrations reports whether the shared Computer Use service is ready, which running app currently owns Chronicle's shared recorder, the age of its latest screen frame, and whether an old rollback is still being discovered as a duplicate app.
doppel native-tools status additionally reports the profile assigned the
official built-in-browser engine and which app owns each ChatGPT
browser-extension registration. A locally signed engine still uses the extension
as its route to a real browser; the assigned profile is launched through the
untouched OpenAI-signed app instead. The extension arrives through one
native-messaging registration per Chromium browser. Every ChatGPT app rewrites
that registration to its own extension host while starting, so the owner is
whichever app launched last.
Sharing one browser between instances does not require owning the registration. It only decides whose extension host Chrome launches; once that host is running, its socket is listed in a machine-wide registry that every managed instance can read, so several instances can drive the same Chrome at the same time. They share its tabs, and the browser API's tab claiming is what keeps two agents off the same one. That coordination is the vendor's, not Doppel's: ChatGPT builds the environment its browser client runs in from scratch, so none of Doppel's variables reach it and there is no window or profile preference for Doppel to pin. Giving two instances different browsers is the only separation Doppel can actually enforce.
Ownership still matters when it is wrong: if it points at a runtime whose host
binary is missing, Chrome launches nothing and no instance gets a browser, and if
it points at a stale runtime, every instance uses that older host.
native-tools claim-browser <name> moves it, changing only the path inside the
vendor's existing registration and leaving its host name and allowed origins
untouched. Reconnect the extension afterwards. Each browser is separate, so
--browser <name> moves one without disturbing the rest; Settings lists every
registration with its current owner and assigns them the same way. A claim holds
until another managed app launches and takes it back, which status will show. doppel native-tools status --porcelain exposes
the same evidence plus each managed instance's exact installed path and bundle
identifier for diagnostics. Computer Use can run while several managed instances
are open; agents should use those exact active paths when a display name is not
unique.
The installed app is self-contained. Sparkle 2 ships inside Doppel.app, while
icon tinting, the instance launcher and the error helper are compiled binaries
in the same bundle. Everything else Doppel calls — codesign, security,
sips, iconutil, lsregister — is part of macOS. Building from a checkout
needs Swift Package Manager for the pinned Sparkle dependency and the Xcode
Command Line Tools.
app/build-app.zsh # builds and installs ~/Applications/Doppel.app
app/build-app.zsh /tmp/out # or somewhere else, to leave your installed copy alone
open -a ~/Applications/Doppel.app
app/test.zsh # unit tests for the menu app's parsingLocal builds are signed ad hoc. Set DOPPEL_SIGN_ID to a Developer ID
Application identity and DOPPEL_SPARKLE_PUBLIC_KEY to the release EdDSA
public key to produce a distributable build. Sparkle and Doppel's helpers are
signed inside-out before the outer bundle, with hardened runtime and a secure
timestamp.
app/test.zsh rather than a bare swift test: XCTest ships inside Xcode, not
with the Command Line Tools, and a Mac can have Xcode installed while
xcode-select still points at the Command Line Tools. The script finds a
toolchain that has XCTest instead of asking you to change that global setting.
The qa/ suites need nothing beyond macOS.
With DOPPEL_PROGRESS=1 set, update prepare, update apply, rebuild and create print one doppel-progress<TAB>percent<TAB>step line to stderr per step, which is what fills the app's progress bars. Without it their output is unchanged.
Failed actions show a short message instead of command output. When the disk is writable, the original CLI diagnostics are saved locally to ~/Library/Logs/Doppel/cli-errors.log, readable only by your account.
A menu-bar-only front end (LSUIElement, so no Dock icon). The panel lists
instances with their state and one action each, and offers ChatGPT updates as
a banner. Each instance's window covers permissions, the engine, Remote Access,
rebuild and removal. Settings holds Start at login, the setup guide, secure
signing, Doppel's own updates and the once-per-Mac integrations. Enable
Receive beta updates in Settings › Updates to opt into beta releases.
Turning it off returns future checks to the stable feed; it does not downgrade
an installed beta. The foot of the panel shows Doppel's own version and the
ChatGPT build it is managing, which is what you want to hand over when
reporting a problem.
Doppel --render-ui <file.png> <screen> draws a screen offscreen from fixed
sample data, without a display capture permission. The screens are panel,
panel-attention, panel-progress, panel-setup, instance, settings, settings-updates,
settings-integrations, settings-micro, onboarding and onboarding-create; edit or no
screen draws the New Instance window.
The app is self-contained. The CLI, the engine and the compiled helpers all ship
inside Contents/Resources/doppel, sealed by the app's own signature, so a
downloaded copy works on its own — no checkout, no package manager, no developer
tools. Every action still goes through that bundled CLI, so the two can never
disagree. A checkout at a standard location is used when the app is run from
source, skipping any path that is group- or world-writable. $DOPPEL_CLI can
point somewhere else, but only with DOPPEL_DEV=1 set and only at a binary that
is not group- or world-writable: an installed copy otherwise follows the CLI it
shipped with, the same way the CLI and engine ignore their own overrides. An
advisory lock stops launchd and Finder producing two menu-bar icons.
Start at Login installs a user LaunchAgent (ai.doppel.menubar) rather than using
SMAppService, which needs a Developer ID-signed bundle that local ad-hoc builds
don't have. Turning the toggle off boots the job out and removes the plist.
Sparkle checks the signed appcast.xml on GitHub and verifies every update with
the EdDSA public key sealed into the installed app. Automatic checks are enabled;
Check Now in Settings › Updates provides an immediate manual check. Doppel's own updater is
separate from the coordinated ChatGPT update flow described above.
Ad-hoc builds without a public key keep both automatic and manual update checks
disabled rather than accepting an unsigned feed.
The existing v1.0.0 asset does not contain Sparkle, so the first Sparkle-enabled release must be installed manually once. For a personal ad-hoc release, macOS will block that first downloaded build until you approve it with Open Anyway in Privacy & Security. Later releases are verified with the Sparkle EdDSA key and can install themselves. This is suitable for personal use, but it is not the normal trusted installation experience for public users.
Release packaging is an explicit two-step publication flow:
just sparkle-key # once; keeps the private key in your Keychain
${EDITOR:-open} release-notes/1.1.0.md
export DOPPEL_SIGN_ID="Developer ID Application: Example (TEAMID)"
export NOTARY_KEYCHAIN_PROFILE="doppel-notary"
just package-release 1.1.0 12Back up that private key once to encrypted storage; do not commit the export:
app/.build/artifacts/sparkle/Sparkle/bin/generate_keys \
--account ai.doppel.menubar -x /secure/backup/doppel-ed25519.keyThe recipe requires non-empty Markdown notes at
release-notes/<version>.md, builds a universal app, signs nested code
inside-out with the Developer ID identity, notarises, staples and checks the
result with Gatekeeper, creates the release ZIP, and generates dist/releases/appcast.xml
with the key stored under the ai.doppel.menubar Keychain account. Sparkle
embeds the notes in the signed appcast so its update window always has a What's
New section. The recipe does not upload or change git. Review the ZIP, notes and
feed, use the Markdown as the GitHub release notes, upload the ZIP, then replace
the repository appcast.xml with the generated feed.
Keep the EdDSA private key safe; losing it means existing ad-hoc installs cannot
trust a replacement update key.
Without DOPPEL_SIGN_ID the recipe stops before building. Installing an
ad-hoc release over a notarised one fails Gatekeeper and resets the app's
privacy grants, so an ad-hoc release has to be asked for with
DOPPEL_ALLOW_ADHOC=1.
Doppel can create its own local code-signing identity from the setup guide, from Settings › General › Secure signing, or from the CLI:
bin/doppel-signing status
bin/doppel-signing setup # creates the identity, no Keychain Access needed
bin/doppel-signing removeThe app route also rebuilds every instance afterwards, which is the step that actually makes them adopt the identity.
Clones are signed ad hoc by default, which has two consequences.
The visible one: macOS keychain grants ("Always Allow") are bound to the signature, and an ad-hoc signature changes on every rebuild, so the keychain consent dialog comes back after every vendor update.
The security one: an ad-hoc signature pins nothing. Any process running as your
user can modify a clone and re-sign it (codesign -f -s -), and the result still
passes verification — the instance launcher's signature check cannot tell the
difference. With a stable identity the launcher pins the certificate leaf, and an
attacker's ad-hoc re-seal no longer satisfies it (verified: an ad-hoc bundle fails
any certificate leaf requirement).
The requirement the launcher checks is kept outside the bundle, under
Doppel/pins/, keyed by the bundle's install path. That matters more than it
sounds: the launcher used to read the requirement out of the same Info.plist
it was about to validate, so deleting one key and re-sealing ad hoc dropped it
to a much weaker identifier-only check that the re-seal then satisfied. The
install path is chosen by whoever launches the app, and a tampered bundle
cannot restate it. Read the limits of this below — it is not a boundary against
something already running as you, only one more thing that has to be got right.
doppel-signing setup generates a code-signing certificate with OpenSSL and
imports it into your login keychain, granting codesign access to the key so
signing stays silent. No administrator rights and no trust prompt: the
certificate is deliberately left untrusted, because codesign signs with it
regardless and trusting it would need an admin authorisation. That also means
security find-identity -v will not list it — look for the certificate itself,
which is what Doppel does.
Keep the private key in a keychain you lock; any process running as you can
otherwise ask codesign to sign with it.
A fuller account of what Doppel protects, what it does not, and where a clone is genuinely weaker than the app it was copied from is in docs/THREAT-MODEL.md. The summary below is the short form.
- The vendor's terms may not contemplate running modified copies. Doppel keeps everything local and personal-use; do not redistribute a built instance.
- "ChatGPT" and its icon belong to OpenAI. Instance names and icons you create are your local Finder labels, nothing more. This project is not affiliated with or endorsed by OpenAI.
- Launch-time deep-link registration uses each instance's private scheme, but
OAuth retains OpenAI's registered
codex://connector/oauth_callbackURI. Each clone declares that callback scheme without claiming it during normal launch. The instance starting connector authorization claimscodex://only for that authorization, so the result returns there instead of to the clone launched most recently. Doppel refuses a rebuild if a vendor release changes either interception point and repairs stale shared-handler ownership from old builds. - The in-app browser works inside a locally re-signed clone only by accident
of the vendor's process layout.
ChatGPT gates its browser socket with a native peer check that requires the
connecting process, its parent and its grandparent to carry OpenAI's Team ID
and an allow-listed signing identifier, and (since build 12553) to descend from
the app hosting the socket. An instance's main executable had to be re-signed
locally to carry its own bundle identifier, so it has no Team ID. Until late
September it was the browser client's grandparent and every request failed with
Browser is not available: iab, logged asrejected socket peer reason=missing-code-signing-identityin~/Library/Logs/com.openai.codex/. Current builds run the client a few vendor processes further down, which puts the clone outside the window, so iab works. Nothing short of OpenAI's signing key satisfies the check itself, so a local identity fromdoppel-signingwould not help if that layout changes back.native-tools statustherefore reports, for each running clone, whether its latest in-app browser use was served or refused, read from that clone's own vendor log. A refusal means the layout changed and the profile needs the built-in-browser slot again. That slot launches the selected profile through the untouched official app. It remains the same Doppel account profile, but macOS shows the official ChatGPT identity while it runs. The browser extension is unaffected either way: its check stops at the parent process, so browser control works in every instance and several instances can share one browser. - Instances are unsigned by Apple standards (ad hoc or self-signed), so Gatekeeper
assessment (
spctl) rejects them. They launch fine because they are built locally and never quarantined. - Library validation is disabled in a clone. Re-signing the main executable
while the vendor's frameworks keep their original signature requires it, so a
clone will load any validly-signed library where the vendor app accepts only
the vendor's own. Hardened runtime stays on and the dyld-environment
entitlement is not granted, so
DYLD_INSERT_LIBRARIESinjection is still blocked (verified empirically against the built clones). - Doppel's boundary is your user account, not the OS. Every input it builds from — the repo, the instance directory, the build cache, the recorded pins — is writable by your own user, so a process already running as you can influence a rebuild. Without a local signing identity (above) nothing detects that. With one, tampering fails the pinned requirement, and the pin cannot be removed from inside the bundle; but the same user can still delete the pin record itself, and nothing stops that. Treat Doppel as protecting against accidents and drift, not against local malware.
- Notification and service containers are still shared. Isolation covers the
Electron profile and
CODEX_HOME. Helper processes inside the clone keep the vendor's signature and its app-group entitlements, so app-group containers (notifications, background services) are shared with the primary app and with every other instance. Isolating those means re-signing the helpers, which reintroduces the AMFI kill this design exists to avoid. - Sparkle is disabled inside a clone with Codex's own
CODEX_SPARKLE_ENABLED=falseswitch (plus the legacy scheduled-check and feed safeguards) because an in-place vendor update would drop the launcher and the profile settings, silently merging two accounts. Doppel checks the official appcast, updates the vendor-signed primary, then rebuilds every instance.

