Part of the Codex app-server adapter.
The native relay is optional
Nothing in the gateway depends on the native relay. Unless a live relay is really being used, ChatGPT keeps its own remote control, so a machine without a working relay still works, and a relay that cannot run is set aside quietly instead of restarted forever.
An adapter, or an SSH listener, asks once when it starts whether a relay is
usable: it must be registered for this configuration and native home (its
<state>/native-registration.json must say registered: true), its status.json
must say connected, and its private control socket must answer
within one second. When any of that fails the engine is started without
CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED and keeps its own remote
control, and remoteControl/* is no longer intercepted. The answer is not asked
again while the process runs, so a relay that connects later is used from the
next app start or the next listener start, not from the current one. Gateway
executors are separate: they run headless on the gateway's own SDK, so
gatewayEnvironment always sets that variable and they never try remote control.
A shared native execution host is asked the same question once, when it starts: only a
registered relay whose slot the service is really running (not holding paused) owns the
machine's remote control. A relay that becomes live later takes effect at the next host
start, which an idle restart gives. A relay that is set aside after the host took the
registration for it gets the reverse: the service restarts only the host, once it is
idle, so the app keeps its own remote control.
Paused relay recovery
The unified service keeps the same rule for its native component. Before it
spawns the relay it reads <state>/native-registration.json, and a slot whose
relay was never registered, or was uninstalled, is left paused with reason
not_registered and is never started. A relay with three non-zero exits in a row,
each within 60 s of its start, is left paused with reason failing; a relay killed
by a signal and one whose binary is missing count as such exits, and one whose log
cannot be opened is set aside at once, so a relay that cannot start at all is set aside
rather than retried once a minute forever.
A paused slot writes no log lines and reports no exits, and gateway service status shows paused with its reason instead of a process id; because no
relay process is running, gateway versions does not list one either. Every five
minutes the service looks at the paused relay once and starts it again when
these hold:
| Condition to start a paused relay again | Where it comes from |
|---|---|
| The registration says the relay is registered | <state>/native-registration.json |
| Something a restart would use has changed | dev:inode:size:mtime of the app's codex executable, the relay binary and that registration file; a relay paused for not_registered only needs the registration to exist |
| No ChatGPT app is open | a ps -axo comm= line for /Applications/ChatGPT.app/Contents/MacOS/ChatGPT, the same check native installation makes, so a newly connected relay never competes with an app's own remote control |
| The same relay has been set aside for half an hour | the time it was paused, so a relay that failed once during a busy start-up is started again on its own, and a relay that fails again within a minute of a resume is set aside at once instead of being given a fresh three-strike budget. Two such starts an hour is all a broken relay ever costs. A resumed relay that runs past its first minute gets three strikes again |
A re-check whose questions cannot all be answered leaves the rest of the service
untouched. A process list that does not come counts as an open app, so the slot stays
paused with no new event; only a probe that throws sets it paused again as failing,
with one component_paused.
Consolidation and the two shared-service migrations treat a paused relay as ready: it
is a components_not_running gap only for components that are neither running nor a
paused relay, and they wait for a relay to connect only on a machine whose registration
says one is set up and whose relay slot is running. A native migration additionally
only waits for the machine's own relay to reconnect when that relay was connected
before the migration.
Native catalog discovery
On startup, a bounded native app-server process refreshes its own model metadata.
The adapter copies only public model rows from that engine's configured catalog
or model cache. It validates that the rows are well formed and writes a combined
catalog. Cache identity and authentication are excluded.
The public rows form a combined catalog for gateway executors. The original
native process keeps its native model and account defaults. The adapter registers
only the gateway's own providers (gatefox and subscription_gateway) through process arguments, so
saved gateway chats can resolve that provider when read or resumed. Gateway
executors get it too, and serve openai as a second name for it
(harness identity). It does not
write the native configuration. An unavailable or structurally invalid cache
prevents startup. A cache that lacks models the engine lists starts anyway; this
happens, for example, when another Codex version wrote the cache or the engine
could not refresh its models. Gateway chats get no native metadata for those
models, the adapter writes one line to standard error, and it records
native_catalog_incomplete. A gateway alias that equals any model ID the engine
lists is still refused, whether or not the cache has a row for it.
Settings compatibility
The adapter normalizes three retired feature keys in app-server CLI flags,
thread start/resume/fork overrides, runtime enablement maps, and private snapshots.
It maps features.codex_hooks to features.hooks after reading the SDK's current
settings. An explicit current hooks value in those settings or the incoming
request takes precedence over the legacy alias. Workspace settings are resolved
for the chat's directory; an unavailable required workspace or configuration
layer returns application error -32001 before applying the alias. It removes
features.code_mode_tool_description_first and features.guardianv2.thread_context,
which the pinned SDK ignores. Code Mode, other Guardian settings, and reasoning
choices keep their values. Equivalent legacy and current settings share the same
executor identity. Already loaded sessions keep their settings until they reload.
Metadata discovery also drops those retired CLI keys before starting its SDK
preflight. It removes the legacy hooks alias without overriding the native
hooks setting while reading that base. Normal inference arguments then apply
the compatible alias only when no explicit current setting exists. Shared
execution hosts enforce the same normalization before spawning their SDK,
including requests from older adapters. This normalization rewrites no
native configuration file.
ChatGPT chooses which features.* settings to send from the native engine's
version, but gateway chats run the older pinned SDK. For gateway chats and gateway
executor launches only, the adapter trims feature settings to that SDK's schema.
This covers thread start/resume/fork overrides, executor launch flags and
settings, and the private gateway home snapshot. Runtime enablement maps
(experimentalFeature/enablement/set) are not covered: that request carries no
thread id, so it always goes to the native engine, which is never changed.
The rules follow the SDK. A feature that is not a structured table in the pinned
SDK must be a boolean, so a table with a boolean enabled field becomes that
boolean and any other value is dropped. Structured features, such as
multi_agent_v2, guardianv2 and code_mode, keep only the fields the SDK
accepts, including the strict sub-tables below them (guardianv2.transcript,
guardianv2.review_scope, multi_agent_v2.message_board_remote and the
network_proxy.credentials entries). tool_registry is a plain table in the SDK,
so a boolean there is dropped. The same rules apply to dotted keys, profiles.<name>
in nested, dotted and flat form, and -c flags. A -c value is read as JSON or
inline TOML (bare dotted keys inside an inline table expand to nested tables), and a
flag that cannot be read is dropped. The SDK parses every -c value as TOML, so a
changed table is always written back as inline TOML, never JSON. Keys are read the way
the SDK applies them: an override key, from a request's config or a -c flag, is
split on every dot and never unquoted, so a quoted segment is part of the name, and
nothing is respelled. Inside a value the SDK keeps keys literal, so a dotted key inside
a features or profile table is a name, not a path, and only a profile's own
features table is trimmed.
Trimming never widens what a chat may do. network_proxy limits network access, so
it is never changed in any form: a value the pinned SDK cannot read fails the chat with
the SDK's own error instead of running it with fewer limits.
Static rules cannot know new enum values, the SDK's range checks or fields added
later. If the SDK still refuses a gateway thread/start, thread/resume or
thread/fork with failed to load configuration (code -32600) at a features or
profiles.<name>.features path, and the strict form differs from the request, the
adapter retries once with that strict form: each feature becomes its boolean enabled
or is removed, tool_registry is removed, and guardianv2 is turned off. Off removes
the reviewer's cached fast approvals, so every reviewed action gets a fresh review or
goes to the user, as approvals_reviewer decides; it never approves more than the
reviewer would with default settings. network_proxy stays as sent, and
multi_agent_v2 keeps its normally trimmed table, so the caller's limits on concurrent
threads and spawn model overrides are not replaced by the SDK's looser defaults. A failure at any other path, or one the SDK marks as an
unavailable cloud configuration bundle, is not retried. A failed configuration load
creates no thread, so the retry has no side effects. If the retry fails too, the
original error is returned.
Native chats and native launches are never changed or retried. The
gateway_features_trimmed event records only the site and how many entries changed.
Gateway defaults and native settings
Gateway default selections are saved to private desktop-preferences.json in
the gateway state directory. Model, effort, summary and speed (service tier)
writes are scoped to that file for both gateway and native model choices, and
they require string values with a replace or upsert merge strategy. Writes
to the supported desktop.* display keys go to that same file, but each carries
its own typed value rather than the string rule above; unsupported desktop.*
keys are ordinary native keys and reach the engine. Every other key belongs to
the caller's native configuration, which config/value/write and
config/batchWrite forward to the native engine unchanged: same method, same
params, with filePath, expectedVersion, reloadUserConfig, and
mergeStrategy passed through, and the engine's result or error returned
as-is. When a batch mixes kinds, its native part keeps the caller's own edit
objects in the caller's own order, including an entry the adapter cannot read,
which is forwarded unchanged. The app writes those keys the same way without the
gateway, so a setting changed in ChatGPT still reaches its own configuration.
The adapter itself never writes native configuration on its own initiative.
Desktop registration is the one exception, and it writes only the gateway's own rows:
model_providers.gatefox and model_providers.subscription_gateway, so saved gateway chats open in any
engine. See the provider row.
A client edit that saves that row is still refused, as below, because only
registration maintains it.
Native configuration write protection
A whole request is refused, before anything is written or forwarded, when a
native edit would store gateway identity in native configuration. A key path is
parsed into segments exactly as the Codex engine parses it: dots separate
segments, a segment may open with " to carry separators literally, and inside
quotes a \ escapes the next character. Every rule below reads those segments,
so a quoted spelling such as model_providers."gatefox".base_url
is refused like the dotted one. A gateway default spelled another way, such as
"model", is not a key the adapter can save: it reads as a native key to the
engine, so the write is refused rather than sent. A byte-order mark is not
whitespace here, which is how the engine reads it, and a path made only of
whitespace carries no name and is refused.
| Refused native edit | Error |
|---|---|
A gateway default key given another spelling, such as "model" or desktop."appearanceTheme": the engine reads those segments as a native key, so the adapter neither saves nor forwards it |
Gateway defaults must be written with their plain key |
| A key path the engine cannot parse: empty, or whitespace in the Unicode sense that carries no name, with an empty segment, with a quote that can neither open nor close a segment, or with an unterminated quote or escape | This configuration key is not valid |
A key whose last segment is model set to a gateway alias |
Gateway defaults cannot be saved into a native Codex profile |
model_provider or oss_provider, at the top level or below a profile, set to gatefox or subscription_gateway; a model_providers.gatefox or model_providers.subscription_gateway key, or any key below it, given a value other than null; a model_providers key whose value holds that provider with a non-null value |
Gateway models and the gateway provider cannot be saved into native Codex configuration |
A key whose last segment ends with _model (such as review_model, profiles.work.review_model or features.guardianv2.review_model) set to a gateway alias |
Gateway models and the gateway provider cannot be saved into native Codex configuration |
A value that anywhere holds a gateway alias under a model or *_model key, that provider under a model_provider or oss_provider key, or that provider as a non-null key of a model_providers value. Values are searched through objects and arrays to 65 nested levels, counting the written value as level 1; a value containing a 66th level is refused. |
Gateway models and the gateway provider cannot be saved into native Codex configuration |
A model_catalog_json key, at the top level or anywhere inside a value, set to the gateway's catalog file or to anything inside the gateway state directory. Files and folders that exist are compared by identity, by device and inode, so a symlink, a firmlink such as /System/Volumes/Data/private/var/..., or another letter case of the same name on a case-insensitive volume is refused like the path written out directly; a file or folder that does not exist yet is judged by the identity of its existing ancestor folders as well as by its spellings, the lexical and the resolved one. A path longer than 4096 bytes, the longest any platform accepts and the same cutoff on every platform, is compared by its spelling only, without touching the file system; a single value of about 512 KiB or more (the exact length depends on how many spellings the native home has: 1 MiB divided by that number) is refused outright, because its size charge alone could not fit the budget below. One request may spend at most 16384 units of work on the paths its edits name. A unit is one folder passed while walking a path, whether or not its answer is already remembered; one file-system read (stat, lstat or readlink); or a size charge on every path value judged, of 1 for each full 64 characters of its text, paid each time the value is tried against a base (the native home has up to two spellings) before any of it is resolved. A short name has no size charge, but the first time the guard meets a value it also pays 1 unit per base, and it remembers the answer for that exact value (up to 4096 values): a repeat of a short value costs nothing more, while different short values such as 0/../a, 1/../a and so on spend the budget two units apiece. Each time a value is tried against a base also counts as one judgment, and one request may make at most 65,536 of them, remembered answers included; 20,000 profiles judged against two spellings of the native home make 40,000. The size charge is paid for a repeated path too, and for a path whose first folder does not exist, so the string work of a long path is bounded as well as its reads: no more than a few hundred paths of 4000 bytes, distinct or repeated, fit in one request. Links are followed one folder at a time and each step is charged; the resolver gives up after 40 links, and the operating system stops sooner (32 on macOS, 40 on Linux). A folder shared by several paths is read once, but a deep path still costs a unit for every folder each time it is walked. A request that spends the budget is refused whole with "This configuration write names too many different paths to check" instead of being checked by spelling alone, and so is a request past the judgment cap; a single value too long to ever fit is refused with "This configuration write names a path too long to check". A real batch stays far below this limit: one or two catalog paths of ten folders cost under 150 units, 50 edits about 15 folders deep cost about 3400, and 50 edits 33 folders deep about 6200. Every spelling the engine reads is tried: a ~ on its own or ~/rest is the home directory, and any other relative path is read below the native home in both its lexical and its resolved spelling, never below the directory of the request's filePath. With the native home at ~/.codex and the state directory at ~/.codex/state, that makes state/codex-models.json, ~/.codex/state/codex-models.json and ../.codex/state/codex-models.json all the gateway's catalog; ../state/codex-models.json reaches it only where the state directory sits beside the native home. A name the engine does not expand, such as ~rest, stays one of the caller's own files and is forwarded |
The gateway catalog cannot be saved into native Codex configuration |
On Windows, where the guard cannot read a path, a model_catalog_json key whose value is any string, at the top level or anywhere inside a value; clearing the key with null is still forwarded |
A catalog file path cannot be saved into native Codex configuration on Windows, where the gateway cannot check which file it names |
Any native edit in a request whose filePath is the gateway preference file |
Native configuration changes cannot target the gateway preference file |
Clearing stays possible: an edit whose value is null removes a gateway
provider row or field instead of storing one, so it is forwarded rather than
refused. Chat starts and settings reads overlay the gateway's own catalog
through model_catalog_json; that file is gateway state, and saving its path,
or any file from inside the gateway state directory, into native configuration
is refused like the rows above. The guard compares the file the engine would
open, or the path itself when the engine could not open it, so a link that
points at either one is refused the same way.
A batch that mixes both kinds first checks every gateway-default rule, then
sends the native edits to the engine in their original order, and saves the
private file only after that write succeeds. A native write that fails returns
its own error and saves nothing, and an invalid private part forwards nothing.
A request that carries a native part answers with the engine's result or error;
a write that is private-only answers with the gateway's own saved result. Every
configuration write from one connection, native-only ones included, waits
behind the writes sent before it, so the engine sees them in send order and a
failing write does not hold back the ones queued behind it. The legacy
whole-config config/write stays refused.
The current app also saves its display settings through desktop.* configuration
writes. The adapter validates supported display fields and stores them in that
same private preference file. It merges them over native desktop defaults when
the app reads settings. This allows Max and Ultra display choices to survive
restart in the private file instead of a native one. Pending desktop migration
batches are validated before any preference changes are saved.
Each adapter connection rereads private preferences when reading settings or starting a chat. Preference updates use a shared file lock and merge the latest saved values. Two desktop connections cannot return a cached reasoning level or overwrite an unrelated preference change with an older snapshot.
The installed desktop build recognizes CODEX_CLI_PATH. This is an implementation
hook, not an established public desktop integration API. scripts/codex-desktop-shim
is an executable wrapper for it. The generated desktop wrapper sends only
app-server commands through the adapter; other commands, such as the desktop's
exec-server, run the original CLI directly with no extra process. The unified
service re-registers the wrapper when it starts. It accepts GATEWAY_CONFIG,
GATEWAY_NODE_PATH, and GATEWAY_CODEX_PATH when its defaults do not match the
machine.
The wrapper and the adapter ignore a CODEX_HOME that points inside the gateway's
state directory or its ~/.local/state/gw-exec, gw-native, gw-ssh and
gw-runtime roots. Gateway executors run with such a home, and every command a
gateway chat runs inherits it. When one of those commands started ChatGPT (an agent
restarting the app with open -a ChatGPT), the adapter looked for
models_cache.json in that internal home and the app failed to start with "The
native model catalog is unavailable". With the guard, an agent-started ChatGPT reads
~/.codex exactly as a launch from the Dock does. Any other CODEX_HOME, such as the
isolated home of gateway desktop --native-home, is kept. The adapter records
codex_home_ignored when it drops one (src/native-home-env.ts).
Signed engine launchers
For an app-server command, a newly generated wrapper passes the signed
CodexCLI.app/Contents/MacOS/codex engine to the adapter. ChatGPT's ordinary
codex-cli/bin/codex launcher stays in use for every other command. The shared host
records its verified signed engine separately from the launcher's identity, so both
paths identify the same native writer. Existing
wrappers that still pass the first-party launcher remain compatible: the adapter
resolves that known launcher to the signed engine before it checks ownership. It never
accepts a different executable as the shared writer. This prevents the startup
organization-settings error that a launcher-versus-engine mismatch could surface as.
The launcher verification checks cover generated
wrappers, both host receipt forms, and startup after a service restart.
Legacy desktop launch overrides
The gateway no longer sets CODEX_APP_SERVER_FORCE_CLI. With CODEX_CLI_PATH
alone, new local chats use the gateway catalog while durable chats and the
desktop's SSH connection rows keep their native behavior: an SSH row runs Codex
on the remote Mac, and gateway models reach it only through that Mac's own
registration.
An older gateway also exported CODEX_APP_SERVER_FORCE_CLI=1, which ran durable chats and
SSH rows as extra local app-servers. Registration removes that value only when a registration receipt
proves the gateway set it; after upgrading, run gateway desktop register, check gateway desktop status
shows legacyForceCli: false and forceCliSet: false (status counts the variable as set when anything
remains after it removes exactly one trailing newline), then restart ChatGPT once. While the session still
names an older gateway wrapper, status reports staleWrapper: true and activeForFutureLaunches: false
until a register writes the current one. Details and the ownership rules are in
legacy FORCE_CLI cleanup.
Desktop startup reads organization requirements and native configuration through the adapter before opening the main UI. A backend startup failure can therefore appear as an organization-settings error.
Native sharing compatibility
The host shares only app engines verified for sharing in
hostVerifiedAppEngines (src/native-engine-versions.ts). The newest verified
engine is 0.167.0-alpha.3.2; earlier verified versions remain accepted. The relay
has its own accepted list and does not share the host's engine
(verified app engines).
If the app's native engine is not in the verified list (for example after an app update),
the host starts without native sharing and keeps offering gateway chats;
it records native_sharing_unavailable with a fixed reason (sdk_version,
sdk_signature or other). When the plan also shares gateway chats, the desktop
adapter then falls back to its own native engine and records native_sharing_fallback
with reason: other and holders, how many private engines hold the native home once
its lease is taken. Gateway chats keep using the host. The relay cannot use the host's
engine either, but a registered relay that is live keeps remote control available while
the fallback lasts: each phone stream runs the gateway router, whose adapter runs a
private native engine of its own on the same home, and the SDK refuses a second writer
for a chat another engine holds
(relay sessions). The fallback lasts
until a release verifies the app's engine. It runs under a private native-home lease,
like the host-down fallback below.
Private native-home ownership
Private leases share the native home with each other and never with the host's writer. The Mac's own ChatGPT app and a connected machine's SSH session therefore each run their own engine on the same home, as stock Codex runs one app-server for the app and another for an SSH remote. A chat open in both at once carries the same risk it carries in stock Codex; the gateway adds no per-chat lock, and its own per-chat records (the chat registry and the speed, reasoning and compaction stores) are merged under a file lock and replaced atomically, so two adapters never lose each other's entries.
| On one native home | Together? |
|---|---|
| Private engines of this release (any number) | Yes |
| A private engine and the host's shared native writer | No: a host that regains native sharing claims the home only after every private engine has exited, and a private engine never starts while the host's writer runs |
| A private engine of this release and one that release 0.4.5 or earlier started | No: the older engine keeps the home to itself, and a new adapter fails closed naming its process, until that connection moves to this release (an idle adapter does after an install) |
Each private engine keeps a receipt of its own beside the native-home receipt, and
owner.json names them for older releases while any is live; the rules are in
architecture.
Host-down fallback
If the host stays down through the adapter's 20-second wait, the desktop adapter
also falls back to its own native engine, so native chats keep working. It does so
only when nothing still owns the native home: no live or starting native engine,
and nothing answering at the host's native socket. Before it starts the engine, it
takes a private native-home lease, and it holds that lease until the engine exits. A
host that comes back meanwhile therefore finds a live owner: it keeps serving gateway
chats, refuses native ownership, and claims the native home at the first native
request after every fallback has ended, so the next adapter start returns to sharing.
The adapter records native_sharing_fallback with reason: host_unavailable. An
adapter that starts while the host refuses native ownership that way, such as an SSH
session after a reboot where the app's adapter started first, does not fail: it joins
the private engines with one of its own and records native_sharing_fallback with
reason: native_owner. It only joins: when no private engine is left by then, it asks
the host once more and shares the host's engine, and a second refusal fails the
connection. Chats its engine verifies as native never wait for the host;
gateway chats still need the host and fail closed until it returns. A host that does
not match the adapter, a changed host socket, an unreadable host receipt, a live host
writer or a private engine of release 0.4.5 or earlier still fails the connection.
Several adapters may each
run such a fallback engine on one native home, by the sharing rule above, and the
remote-control rules for a private engine apply to each. The lease rules are in
architecture.
Launch setting admission
Natively, the desktop is the only launcher of its app-server. With shared native ownership, the relay or an SSH client can start the writer first with fewer settings. The host compares launches by setting key, not by exact argument list:
| Request compared with the running writer | Result |
|---|---|
| Same settings, or a subset (relay ⊂ SSH ⊂ desktop) | Attaches to the running writer |
| Only adds settings, and every loaded chat is idle | Replaces the writer with the richer launch |
| Only adds settings while a chat is working | Refused; the desktop's Retry succeeds after it finishes |
| Changes a value, a profile, the directory, or the provider registration | Refused; ownership is preserved |
The desktop adds features.code_mode_host and its bundled plugin MCP toggles.
The analytics-default flag is ignored. A replacement disconnects the old writer's
clients, as an app restart would. A writer that a settings-free client starts
later reuses the richest launch the host has seen.
Fresh desktop launch
To test a fresh desktop launch, quit the app normally with Command-Q and keep the gateway daemon running. The launcher refuses to stop a running app. It starts the signed binary directly with explicit environment variables and saves a private receipt with its PID. A launch receipt does not prove rendered model selection.
gateway desktop --config /absolute/path/gateway.local.json
Use --native-home PATH for an isolated native test home. Omit it to keep the
original native account home. The launcher does not edit either native config
or authentication files. LaunchServices can reuse an existing instance, so an
open -a invocation alone is insufficient evidence that overrides took effect.
Desktop registration
gateway desktop register stores a managed per-user LaunchAgent and sets
CODEX_CLI_PATH for future launches. The agent restores it at login and does not
open or quit the app. Registration refuses an unrelated existing CLI override and
clears only a legacy CODEX_APP_SERVER_FORCE_CLI=1 that an older gateway set.
gateway desktop unregister removes its own overrides.
This keeps normal Dock launches configured after initial activation without
editing native configuration.