codex-multi-auth 2.8.3 → 2.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/codex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from "node:child_process";
3
+ import { execFile, execFileSync, spawn } from "node:child_process";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import {
6
6
  chmodSync,
@@ -76,6 +76,8 @@ const APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV =
76
76
  "CODEX_MULTI_AUTH_REAL_CODEX_HOME";
77
77
  const APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV =
78
78
  "CODEX_MULTI_AUTH_APP_ROTATION_USE_CANONICAL_HOME";
79
+ const APP_RUNTIME_HELPER_INSTALL_APP_SERVER_SHIM_ENV =
80
+ "CODEX_MULTI_AUTH_APP_ROTATION_INSTALL_APP_SERVER_SHIM";
79
81
  const APP_SERVER_CONFIG_ARGS_ENV =
80
82
  "CODEX_MULTI_AUTH_APP_SERVER_CONFIG_ARGS_JSON";
81
83
  const APP_RUNTIME_HELPER_STATUS_FILE =
@@ -83,6 +85,29 @@ const APP_RUNTIME_HELPER_STATUS_FILE =
83
85
  const APP_RUNTIME_HELPER_OWNER_FILE =
84
86
  RUNTIME_CONSTANTS.APP_RUNTIME_HELPER_OWNER_FILE;
85
87
  const DEFAULT_APP_RUNTIME_HELPER_IDLE_MS = 12 * 60 * 60 * 1000;
88
+ // Absolute ceiling on a helper's life, independent of the idle tracker. The
89
+ // idle reaper depends on activity accounting being correct; any bug there —
90
+ // PID reuse briefly reviving a dead owner is the observed one — previously
91
+ // produced an *unbounded* leak because nothing else bounded the process.
92
+ const DEFAULT_APP_RUNTIME_HELPER_MAX_LIFETIME_MS = 24 * 60 * 60 * 1000;
93
+ // A helper whose launcher is gone is not idle in the same sense as one whose
94
+ // launcher is sitting at a prompt: nobody is coming back to it unless a
95
+ // detached consumer picked it up. The detach grace below hands helpers off
96
+ // optimistically — every launcher that exits cleanly within the window leaves
97
+ // its helper running — so short forwarded commands strand helpers that then
98
+ // hold the full idle timeout with no owner and no traffic. This window is the
99
+ // idle timeout that applies from the moment the owner is confirmed dead, and
100
+ // it only ever fires with zero open client connections, so a consumer that
101
+ // really did take the handoff is never reaped out from under.
102
+ const DEFAULT_APP_RUNTIME_HELPER_DETACHED_IDLE_MS = 15 * 60 * 1000;
103
+ const APP_RUNTIME_HELPER_OWNER_START_TIME_ENV =
104
+ "CODEX_MULTI_AUTH_APP_ROTATION_OWNER_START_TIME_MS";
105
+ // Re-verify owner identity (not just PID liveness) at most this often; a
106
+ // process spawn per tick would cost more than the leak it prevents.
107
+ const APP_RUNTIME_HELPER_OWNER_IDENTITY_RECHECK_MS = 60_000;
108
+ // Status telemetry heartbeat: the tick's job is the timeout check, so status
109
+ // is republished only on change, plus a heartbeat so freshness readers work.
110
+ const APP_RUNTIME_HELPER_STATUS_HEARTBEAT_MS = 60_000;
86
111
  const DEFAULT_APP_RUNTIME_HELPER_DETACH_GRACE_MS = 5_000;
87
112
  const APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS = 15_000;
88
113
  const APP_SERVER_SHIM_DIR_NAME = "app-server-shims";
@@ -92,37 +117,49 @@ const DEFAULT_STATUS_QUOTA_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
92
117
  const STATUS_QUOTA_REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
93
118
  const STATUS_QUOTA_REFRESH_LOCK_DIR = "status-quota-refresh.lock";
94
119
  const STARTUP_UPDATE_NOTICE_TIMED_OUT = Symbol("startup-update-notice-timed-out");
95
- let shadowHomeCleanupBusyFailuresRemaining = Number.parseInt(
96
- process.env.CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES ?? "0",
97
- 10,
98
- );
99
- let shadowHomeCleanupPreflightReadBusyFailuresRemaining = Number.parseInt(
100
- process.env.CODEX_MULTI_AUTH_TEST_SHADOW_PREFLIGHT_READ_BUSY_FAILURES ?? "0",
101
- 10,
102
- );
103
- let shadowHomeSyncLockRecreateStaleCount = Number.parseInt(
104
- process.env.CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_RECREATE_STALE_COUNT ?? "0",
105
- 10,
120
+
121
+ // This wrapper is published (`package.json` ships `scripts/codex.js`), so every
122
+ // fault injector below runs in users' installs. Two guards keep them inert
123
+ // there. The first is an explicit opt-in: a single, greppable switch that must
124
+ // be set alongside any counter, so one stray counter in a shell profile or a CI
125
+ // environment cannot arm anything. The second is a strict parse — `parseInt`
126
+ // happily reads "2abc" as 2 and "1e3" as 1, which is how a value that was never
127
+ // meant to be a count arms an injector — so only a plain run of digits counts
128
+ // and everything else is zero. Zero means "never inject".
129
+ const TEST_FAULT_INJECTION_ENV = "CODEX_MULTI_AUTH_TEST_FAULT_INJECTION";
130
+
131
+ function resolveTestFaultInjectionCount(name, env = process.env) {
132
+ if (env[TEST_FAULT_INJECTION_ENV] !== "1") return 0;
133
+ const raw = (env[name] ?? "").trim();
134
+ if (!/^\d+$/.test(raw)) return 0;
135
+ const parsed = Number.parseInt(raw, 10);
136
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
137
+ }
138
+
139
+ let shadowHomeCleanupBusyFailuresRemaining = resolveTestFaultInjectionCount(
140
+ "CODEX_MULTI_AUTH_TEST_SHADOW_CLEANUP_BUSY_FAILURES",
106
141
  );
107
- let shadowHomeSyncMetadataBusyFailuresRemaining = Number.parseInt(
108
- process.env.CODEX_MULTI_AUTH_TEST_SHADOW_SYNC_METADATA_BUSY_FAILURES ?? "0",
109
- 10,
142
+ let shadowHomeCleanupPreflightReadBusyFailuresRemaining =
143
+ resolveTestFaultInjectionCount(
144
+ "CODEX_MULTI_AUTH_TEST_SHADOW_PREFLIGHT_READ_BUSY_FAILURES",
145
+ );
146
+ let shadowHomeSyncLockRecreateStaleCount = resolveTestFaultInjectionCount(
147
+ "CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_RECREATE_STALE_COUNT",
110
148
  );
111
- let shadowHomeSyncLockOwnerWriteFailuresRemaining = Number.parseInt(
112
- process.env.CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_OWNER_WRITE_FAILURES ?? "0",
113
- 10,
149
+ let shadowHomeSyncMetadataBusyFailuresRemaining = resolveTestFaultInjectionCount(
150
+ "CODEX_MULTI_AUTH_TEST_SHADOW_SYNC_METADATA_BUSY_FAILURES",
114
151
  );
152
+ let shadowHomeSyncLockOwnerWriteFailuresRemaining =
153
+ resolveTestFaultInjectionCount(
154
+ "CODEX_MULTI_AUTH_TEST_SHADOW_LOCK_OWNER_WRITE_FAILURES",
155
+ );
115
156
  let appServerShimFileCleanupBusyFailuresRemaining =
116
- Number.parseInt(
117
- process.env.CODEX_MULTI_AUTH_TEST_APP_SERVER_SHIM_FILE_CLEANUP_BUSY_FAILURES ??
118
- "0",
119
- 10,
120
- ) || 0;
121
- let appServerShimCopyBusyFailuresRemaining =
122
- Number.parseInt(
123
- process.env.CODEX_MULTI_AUTH_TEST_APP_SERVER_SHIM_COPY_BUSY_FAILURES ?? "0",
124
- 10,
125
- ) || 0;
157
+ resolveTestFaultInjectionCount(
158
+ "CODEX_MULTI_AUTH_TEST_APP_SERVER_SHIM_FILE_CLEANUP_BUSY_FAILURES",
159
+ );
160
+ let appServerShimCopyBusyFailuresRemaining = resolveTestFaultInjectionCount(
161
+ "CODEX_MULTI_AUTH_TEST_APP_SERVER_SHIM_COPY_BUSY_FAILURES",
162
+ );
126
163
  const shadowHomeCleanupRetryMarkerDir =
127
164
  (process.env.CODEX_MULTI_AUTH_TEST_SHADOW_RETRY_MARKER_DIR ?? "").trim();
128
165
  let warnedInvalidRuntimeRotationProxyEnv = false;
@@ -1769,6 +1806,10 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) {
1769
1806
  compatibility,
1770
1807
  rawArgs,
1771
1808
  );
1809
+ if (runtimeProxyContext.startupError) {
1810
+ console.error(runtimeProxyContext.startupError);
1811
+ return 1;
1812
+ }
1772
1813
  const result = await forwardToRealCodexOnce(
1773
1814
  codexBin,
1774
1815
  runtimeProxyContext.args,
@@ -3770,10 +3811,21 @@ function installRuntimeRotationAppServerCliShim(forwardedEnv, configArgs = []) {
3770
3811
  return shimDir;
3771
3812
  }
3772
3813
 
3773
- function resolveRuntimeRotationAppHelperStatusPath(env = process.env) {
3814
+ // With a helper PID the path is per-helper, mirroring the owner files below —
3815
+ // N concurrent helpers each publish their own status instead of last-writer-
3816
+ // winning one shared file. Without a PID it is the legacy shared path, kept
3817
+ // only so readers can still see a helper from before this change.
3818
+ function resolveRuntimeRotationAppHelperStatusPath(env = process.env, helperPid) {
3774
3819
  const multiAuthDir =
3775
3820
  resolveOriginalMultiAuthDir(env) ?? join(resolveCodexHomeDir(env), "multi-auth");
3776
- return join(multiAuthDir, APP_RUNTIME_HELPER_STATUS_FILE);
3821
+ const statusFileName =
3822
+ typeof helperPid === "number" && Number.isInteger(helperPid) && helperPid > 0
3823
+ ? APP_RUNTIME_HELPER_STATUS_FILE.replace(
3824
+ /\.json$/i,
3825
+ `.${helperPid}.json`,
3826
+ )
3827
+ : APP_RUNTIME_HELPER_STATUS_FILE;
3828
+ return join(multiAuthDir, statusFileName);
3777
3829
  }
3778
3830
 
3779
3831
  function resolveRuntimeRotationAppHelperOwnerPath(env = process.env, helperPid) {
@@ -3846,6 +3898,96 @@ function resolveRuntimeRotationAppHelperOwnerPid(env = process.env) {
3846
3898
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
3847
3899
  }
3848
3900
 
3901
+ function resolveRuntimeRotationAppHelperMaxLifetimeMs(env = process.env) {
3902
+ const parsed = Number.parseInt(
3903
+ env.CODEX_MULTI_AUTH_APP_ROTATION_MAX_LIFETIME_MS ?? "",
3904
+ 10,
3905
+ );
3906
+ // 0 disables the ceiling explicitly; anything unset or invalid gets the
3907
+ // default rather than unbounded life.
3908
+ return Number.isFinite(parsed) && parsed >= 0
3909
+ ? parsed
3910
+ : DEFAULT_APP_RUNTIME_HELPER_MAX_LIFETIME_MS;
3911
+ }
3912
+
3913
+ function resolveRuntimeRotationAppHelperOwnerStartTimeMs(env = process.env) {
3914
+ const parsed = Number.parseInt(
3915
+ env[APP_RUNTIME_HELPER_OWNER_START_TIME_ENV] ?? "",
3916
+ 10,
3917
+ );
3918
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
3919
+ }
3920
+
3921
+ // `lstart` is strftime-formatted and locale-sensitive; Date.parse on a
3922
+ // localized string is implementation-defined and can yield NaN, which would
3923
+ // silently disable the identity check. Both readers pin the C locale so both
3924
+ // sides of every comparison parse the same shape.
3925
+ function parseProcessStartTimeOutput(out) {
3926
+ const trimmed = (out ?? "").trim();
3927
+ if (!trimmed) return null;
3928
+ const parsed = Date.parse(trimmed);
3929
+ return Number.isFinite(parsed) ? parsed : null;
3930
+ }
3931
+
3932
+ // The kernel's start time for a PID, in epoch ms — the identity that survives
3933
+ // PID reuse. Null on platforms without `ps` (Windows) or for a PID that is
3934
+ // already gone; callers must treat null as "identity unknown" and fall back
3935
+ // to bare liveness rather than declaring the process dead. Synchronous —
3936
+ // launcher/sweep use only; the helper's tick uses the async variant below.
3937
+ //
3938
+ // Windows short-circuits rather than spawning: there is no `ps` there, so the
3939
+ // probe could only ever fail, and it is not called once — the launcher probes
3940
+ // itself on every launch and the sweep probes up to `probeBudget` candidates.
3941
+ // Paying a process spawn per probe to learn nothing is the whole cost. Windows
3942
+ // therefore runs on bare liveness, and the 24h lifetime ceiling is what bounds
3943
+ // a leak there.
3944
+ function readProcessStartTimeMs(pid) {
3945
+ if (process.platform === "win32") return null;
3946
+ try {
3947
+ return parseProcessStartTimeOutput(
3948
+ execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
3949
+ encoding: "utf8",
3950
+ stdio: ["ignore", "pipe", "ignore"],
3951
+ env: { ...process.env, LC_ALL: "C" },
3952
+ timeout: 2_000,
3953
+ }),
3954
+ );
3955
+ } catch {
3956
+ return null;
3957
+ }
3958
+ }
3959
+
3960
+ // Async variant for the helper's status tick, which runs on the live rotation
3961
+ // proxy's event loop: a wedged `ps` must stall a background probe, never an
3962
+ // in-flight Responses stream. Same parse, same C locale, same null contract,
3963
+ // and the same Windows short-circuit.
3964
+ function readProcessStartTimeMsAsync(pid, onResult) {
3965
+ if (process.platform === "win32") {
3966
+ onResult(null);
3967
+ return;
3968
+ }
3969
+ let child;
3970
+ try {
3971
+ child = execFile(
3972
+ "ps",
3973
+ ["-o", "lstart=", "-p", String(pid)],
3974
+ {
3975
+ encoding: "utf8",
3976
+ env: { ...process.env, LC_ALL: "C" },
3977
+ timeout: 2_000,
3978
+ },
3979
+ (error, stdout) => {
3980
+ onResult(error ? null : parseProcessStartTimeOutput(stdout));
3981
+ },
3982
+ );
3983
+ } catch {
3984
+ onResult(null);
3985
+ return;
3986
+ }
3987
+ // The probe must not keep the helper's event loop referenced on shutdown.
3988
+ child.unref?.();
3989
+ }
3990
+
3849
3991
  function isProcessAlive(pid) {
3850
3992
  try {
3851
3993
  process.kill(pid, 0);
@@ -3855,8 +3997,109 @@ function isProcessAlive(pid) {
3855
3997
  }
3856
3998
  }
3857
3999
 
3858
- function isRuntimeRotationAppHelperOwnerAlive(pid) {
3859
- return isProcessAlive(pid);
4000
+ // `kill(pid, 0)` answers "does *a* process hold this integer", never "is this
4001
+ // still my owner". PIDs recycle, and a helper that mistakes a recycled PID for
4002
+ // its owner pushes its idle deadline forward — a ratchet, because one false
4003
+ // "alive" is never corrected by later true "dead"s, which is how helpers were
4004
+ // observed running 33 hours past a 12-hour timeout. Identity is the owner's
4005
+ // process start time, captured by the launcher at spawn: a recycled PID
4006
+ // necessarily has a later start time, so the match fails and the helper
4007
+ // correctly sees a dead owner. When the start time is unknown (no `ps`, or a
4008
+ // pre-upgrade launcher), behavior degrades to the bare liveness check.
4009
+ //
4010
+ // The verdict is deliberately three-valued. "No owner PID was recorded" and
4011
+ // "the owner is confirmed dead" are different facts, and collapsing them into
4012
+ // one `false` makes a helper launched without an owner — invoked directly, or
4013
+ // spawned by a pre-upgrade launcher that sets no owner PID — start the
4014
+ // detached clock on its very first tick and reap itself while it is still
4015
+ // serving. `unknown` means neither branch fires, which is exactly what the
4016
+ // pre-#664 `if (ownerPid && isAlive(ownerPid))` guard did.
4017
+ const OWNER_ALIVE = "alive";
4018
+ const OWNER_DEAD = "dead";
4019
+ const OWNER_UNKNOWN = "unknown";
4020
+
4021
+ function createRuntimeRotationAppHelperOwnerLivenessCheck(
4022
+ ownerPid,
4023
+ expectedStartTimeMs,
4024
+ recheckIntervalMs = APP_RUNTIME_HELPER_OWNER_IDENTITY_RECHECK_MS,
4025
+ ) {
4026
+ let lastIdentityCheckedAt = 0;
4027
+ let lastIdentityVerdict = true;
4028
+ let probeInFlight = false;
4029
+ return (currentTime) => {
4030
+ if (!ownerPid) {
4031
+ return OWNER_UNKNOWN;
4032
+ }
4033
+ if (!isProcessAlive(ownerPid)) {
4034
+ return OWNER_DEAD;
4035
+ }
4036
+ if (expectedStartTimeMs === null) {
4037
+ return OWNER_ALIVE;
4038
+ }
4039
+ // The probe is asynchronous and single-flight: the tick runs on the live
4040
+ // proxy's event loop, so it always answers from the last verdict and the
4041
+ // probe updates it in the background — a stale verdict is tolerated by
4042
+ // design (one recheck window against a 12h timeout), and single-flight
4043
+ // means a wedged `ps` holds one child, not one per tick.
4044
+ if (
4045
+ !probeInFlight &&
4046
+ currentTime - lastIdentityCheckedAt >= recheckIntervalMs
4047
+ ) {
4048
+ lastIdentityCheckedAt = currentTime;
4049
+ probeInFlight = true;
4050
+ readProcessStartTimeMsAsync(ownerPid, (actualStartTimeMs) => {
4051
+ probeInFlight = false;
4052
+ // A failed read is "identity unknown", not "owner dead": under the
4053
+ // process-table pressure this fix exists for, fork itself can fail,
4054
+ // and declaring a live owner dead would kill the proxy out from
4055
+ // under an active session. Keep the verdict and retry next window.
4056
+ if (actualStartTimeMs !== null) {
4057
+ lastIdentityVerdict = actualStartTimeMs === expectedStartTimeMs;
4058
+ }
4059
+ });
4060
+ }
4061
+ return lastIdentityVerdict ? OWNER_ALIVE : OWNER_DEAD;
4062
+ };
4063
+ }
4064
+
4065
+ function resolveRuntimeRotationAppHelperTickMs(idleTimeoutMs, detachedIdleMs) {
4066
+ const shortestWindowMs =
4067
+ detachedIdleMs > 0 ? Math.min(idleTimeoutMs, detachedIdleMs) : idleTimeoutMs;
4068
+ return Math.min(1_000, Math.max(50, Math.floor(shortestWindowMs / 2)));
4069
+ }
4070
+
4071
+ // `lastIdentityVerdict` starts optimistic, so the first tick reports the owner
4072
+ // alive while the async `ps` probe is still in flight, and a helper adopted by
4073
+ // a recycled owner PID keeps refreshing its activity clock until the first
4074
+ // recheck lands. Against the 12h default that is deliberate and harmless. It
4075
+ // stops being harmless the moment the windows are compressed: a 60s recheck
4076
+ // pinned against a 250ms idle override — how the lifecycle tests run — is
4077
+ // longer than the entire window under test, so whether the verdict ever flips
4078
+ // comes down to probe timing. Scaling the recheck to the shortest window that
4079
+ // can fire makes the flip a property of the code rather than a race, and
4080
+ // production is untouched because both defaults are hours.
4081
+ function resolveRuntimeRotationAppHelperOwnerRecheckMs(
4082
+ idleTimeoutMs,
4083
+ detachedIdleMs,
4084
+ ) {
4085
+ const shortestWindowMs =
4086
+ detachedIdleMs > 0 ? Math.min(idleTimeoutMs, detachedIdleMs) : idleTimeoutMs;
4087
+ return Math.min(
4088
+ APP_RUNTIME_HELPER_OWNER_IDENTITY_RECHECK_MS,
4089
+ Math.max(50, Math.floor(shortestWindowMs / 4)),
4090
+ );
4091
+ }
4092
+
4093
+ function resolveRuntimeRotationAppHelperDetachedIdleMs(env = process.env) {
4094
+ const parsed = Number.parseInt(
4095
+ env.CODEX_MULTI_AUTH_APP_ROTATION_DETACHED_IDLE_MS ?? "",
4096
+ 10,
4097
+ );
4098
+ // 0 disables the detached window explicitly, leaving a stranded helper on
4099
+ // the full idle timeout — the pre-fix behavior, for anyone who depends on it.
4100
+ return Number.isFinite(parsed) && parsed >= 0
4101
+ ? parsed
4102
+ : DEFAULT_APP_RUNTIME_HELPER_DETACHED_IDLE_MS;
3860
4103
  }
3861
4104
 
3862
4105
  function resolveRuntimeRotationAppHelperDetachGraceMs(env = process.env) {
@@ -3894,13 +4137,181 @@ function pickRuntimeRotationAppHelperEnv(env) {
3894
4137
 
3895
4138
  function writeRuntimeRotationAppHelperStatus(payload, env = process.env) {
3896
4139
  try {
3897
- const statusPath = resolveRuntimeRotationAppHelperStatusPath(env);
4140
+ const statusPath = resolveRuntimeRotationAppHelperStatusPath(
4141
+ env,
4142
+ payload?.pid,
4143
+ );
3898
4144
  writeOwnerOnlyJsonFileAtomicSync(statusPath, payload);
3899
4145
  } catch {
3900
4146
  // Best-effort status only; the helper must not fail because telemetry is unavailable.
3901
4147
  }
3902
4148
  }
3903
4149
 
4150
+ let helperMetadataCleanupBusyFailuresRemaining = resolveTestFaultInjectionCount(
4151
+ "CODEX_MULTI_AUTH_TEST_HELPER_METADATA_CLEANUP_BUSY_FAILURES",
4152
+ );
4153
+
4154
+ function maybeThrowSimulatedHelperMetadataFileError() {
4155
+ if (
4156
+ Number.isFinite(helperMetadataCleanupBusyFailuresRemaining) &&
4157
+ helperMetadataCleanupBusyFailuresRemaining > 0
4158
+ ) {
4159
+ helperMetadataCleanupBusyFailuresRemaining -= 1;
4160
+ const error = new Error("simulated EBUSY");
4161
+ error.code = "EBUSY";
4162
+ throw error;
4163
+ }
4164
+ }
4165
+
4166
+ // Windows can hold a transient lock on a file another process just closed, so
4167
+ // every metadata deletion goes through the shared retry rather than a bare
4168
+ // rmSync — a swallowed EBUSY here is how stale files outlive their sweep.
4169
+ function removeHelperMetadataFileWithRetry(targetPath) {
4170
+ try {
4171
+ withSynchronousFileOperationRetry(() => {
4172
+ maybeThrowSimulatedHelperMetadataFileError();
4173
+ rmSync(targetPath, { force: true });
4174
+ });
4175
+ } catch {
4176
+ // Best-effort metadata cleanup only; the next sweep retries.
4177
+ }
4178
+ }
4179
+
4180
+ function removeRuntimeRotationAppHelperOwnerFile(env = process.env, helperPid) {
4181
+ removeHelperMetadataFileWithRetry(
4182
+ resolveRuntimeRotationAppHelperOwnerPath(env, helperPid),
4183
+ );
4184
+ }
4185
+
4186
+ // Owner and status files are written per helper PID and removed on clean
4187
+ // helper exit; a killed helper leaves its files behind. This sweep runs when a
4188
+ // launcher starts the next helper, mirroring the app-server shim-dir sweep:
4189
+ // any per-PID metadata whose helper is no longer alive is stale, as is the
4190
+ // legacy shared status file once the PID recorded inside it is dead. Terminal
4191
+ // status stamps ("idle-timeout", "stopped") therefore survive until the next
4192
+ // helper launch — long enough to be read, without accumulating forever.
4193
+ function sweepStaleRuntimeRotationAppHelperMetadata(env = process.env) {
4194
+ const multiAuthDir =
4195
+ resolveOriginalMultiAuthDir(env) ?? join(resolveCodexHomeDir(env), "multi-auth");
4196
+ let entries = [];
4197
+ try {
4198
+ entries = readdirSync(multiAuthDir, { withFileTypes: true });
4199
+ } catch {
4200
+ return;
4201
+ }
4202
+ // The same filename contract as `runtimeHelperPerPidPattern` in
4203
+ // lib/runtime-constants.ts, re-derived here from the same two constants
4204
+ // rather than imported: this wrapper has to keep working before `dist/` is
4205
+ // built (see `loadRuntimeConstants`), and a sweep that silently matched
4206
+ // nothing because an import failed would delete nothing and report success.
4207
+ // A change to the shape there is a change here.
4208
+ const perPidPattern = (baseName) =>
4209
+ new RegExp(
4210
+ `^${baseName.replace(/\.json$/i, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(\\d+)\\.json$`,
4211
+ "i",
4212
+ );
4213
+ const statusPattern = perPidPattern(APP_RUNTIME_HELPER_STATUS_FILE);
4214
+ const ownerPattern = perPidPattern(APP_RUNTIME_HELPER_OWNER_FILE);
4215
+ // A live kill(pid, 0) is not proof the helper is alive — the PID may have
4216
+ // been recycled since a SIGKILLed helper left its files behind, and a
4217
+ // recycled PID would otherwise shield the stale file from every future
4218
+ // sweep. When the file records when its helper started, a current process
4219
+ // whose kernel start time is meaningfully later cannot be that helper.
4220
+ // Identity probes are bounded: results are memoized per PID for the sweep
4221
+ // (the same PID backs both a status and an owner file), and at most a
4222
+ // handful of `ps` spawns run per launch — candidates past the cap are
4223
+ // treated as not-dead and the next launch finishes the work. Dead-PID
4224
+ // files, the overwhelming majority after a leak, never probe at all.
4225
+ const probedStartTimes = new Map();
4226
+ let probeBudget = 20;
4227
+ const probeStartTime = (pid) => {
4228
+ if (probedStartTimes.has(pid)) return probedStartTimes.get(pid);
4229
+ if (probeBudget <= 0) return undefined;
4230
+ probeBudget -= 1;
4231
+ const startTime = readProcessStartTimeMs(pid);
4232
+ probedStartTimes.set(pid, startTime);
4233
+ return startTime;
4234
+ };
4235
+ const isSweepCandidateDead = (pid, filePath) => {
4236
+ if (!isProcessAlive(pid)) return true;
4237
+ let recordedAt = null;
4238
+ try {
4239
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
4240
+ if (parsed && typeof parsed === "object") {
4241
+ recordedAt =
4242
+ typeof parsed.startedAt === "number"
4243
+ ? parsed.startedAt
4244
+ : typeof parsed.createdAt === "number"
4245
+ ? parsed.createdAt
4246
+ : null;
4247
+ }
4248
+ } catch {
4249
+ return false;
4250
+ }
4251
+ if (recordedAt === null) return false;
4252
+ const actualStartTimeMs = probeStartTime(pid);
4253
+ if (actualStartTimeMs === null || actualStartTimeMs === undefined) {
4254
+ return false;
4255
+ }
4256
+ return actualStartTimeMs > recordedAt + 60_000;
4257
+ };
4258
+ // Classifying a file as stale and deleting it are two moments, and a PID
4259
+ // freed between them can be handed to a helper starting right now — which
4260
+ // then republishes this exact path before the delete lands, and the sweep
4261
+ // erases a live helper's metadata: invisible to `rotation status`, to
4262
+ // runtime account resolution, and to `unbind-app`, reapable only by its own
4263
+ // timers. Deleting only a file whose mtime still matches what was
4264
+ // classified closes that window; a file rewritten underneath us is by
4265
+ // definition not the one judged dead.
4266
+ const removeIfUnchanged = (entryPath, classifiedMtimeMs) => {
4267
+ if (classifiedMtimeMs !== null) {
4268
+ let currentMtimeMs = null;
4269
+ try {
4270
+ currentMtimeMs = statSync(entryPath).mtimeMs;
4271
+ } catch {
4272
+ return;
4273
+ }
4274
+ if (currentMtimeMs !== classifiedMtimeMs) return;
4275
+ }
4276
+ removeHelperMetadataFileWithRetry(entryPath);
4277
+ };
4278
+ for (const entry of entries) {
4279
+ if (!entry.isFile()) continue;
4280
+ const match =
4281
+ statusPattern.exec(entry.name) ?? ownerPattern.exec(entry.name);
4282
+ if (!match) continue;
4283
+ const pid = Number.parseInt(match[1], 10);
4284
+ if (!Number.isInteger(pid) || pid <= 0) continue;
4285
+ const entryPath = join(multiAuthDir, entry.name);
4286
+ let classifiedMtimeMs = null;
4287
+ try {
4288
+ classifiedMtimeMs = statSync(entryPath).mtimeMs;
4289
+ } catch {
4290
+ continue;
4291
+ }
4292
+ if (!isSweepCandidateDead(pid, entryPath)) continue;
4293
+ removeIfUnchanged(entryPath, classifiedMtimeMs);
4294
+ }
4295
+ const legacyStatusPath = join(multiAuthDir, APP_RUNTIME_HELPER_STATUS_FILE);
4296
+ try {
4297
+ const legacyMtimeMs = statSync(legacyStatusPath).mtimeMs;
4298
+ const parsed = JSON.parse(readFileSync(legacyStatusPath, "utf8"));
4299
+ const legacyPid =
4300
+ parsed && typeof parsed === "object" && Number.isInteger(parsed.pid)
4301
+ ? parsed.pid
4302
+ : null;
4303
+ if (
4304
+ legacyPid === null ||
4305
+ legacyPid <= 0 ||
4306
+ isSweepCandidateDead(legacyPid, legacyStatusPath)
4307
+ ) {
4308
+ removeIfUnchanged(legacyStatusPath, legacyMtimeMs);
4309
+ }
4310
+ } catch {
4311
+ // Missing or unreadable legacy status; nothing to sweep.
4312
+ }
4313
+ }
4314
+
3904
4315
  function writeRuntimeRotationAppHelperOwner(
3905
4316
  identityToken,
3906
4317
  helperPid,
@@ -3935,6 +4346,7 @@ function createRuntimeRotationAppHelperStatus({
3935
4346
  identityToken,
3936
4347
  idleTimeoutMs,
3937
4348
  lastActivityAt,
4349
+ idleExpiresAt,
3938
4350
  state,
3939
4351
  }) {
3940
4352
  const proxyStatus =
@@ -3958,7 +4370,13 @@ function createRuntimeRotationAppHelperStatus({
3958
4370
  updatedAt: Date.now(),
3959
4371
  baseUrl: proxyServer?.baseUrl ?? null,
3960
4372
  idleTimeoutMs,
3961
- idleExpiresAt: lastActivityAt + idleTimeoutMs,
4373
+ // The reaper's real deadline, which is the detached window once the owner
4374
+ // is gone. Reporting the raw idle timeout there would tell `rotation
4375
+ // status` a helper has 12h left when it has minutes.
4376
+ idleExpiresAt:
4377
+ typeof idleExpiresAt === "number"
4378
+ ? idleExpiresAt
4379
+ : lastActivityAt + idleTimeoutMs,
3962
4380
  totalRequests: proxyStatus.totalRequests ?? 0,
3963
4381
  upstreamRequests: proxyStatus.upstreamRequests ?? 0,
3964
4382
  retries: proxyStatus.retries ?? 0,
@@ -3979,21 +4397,97 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
3979
4397
  let closing = false;
3980
4398
  const startedAt = Date.now();
3981
4399
  const idleTimeoutMs = resolveRuntimeRotationAppHelperIdleMs();
4400
+ const maxLifetimeMs = resolveRuntimeRotationAppHelperMaxLifetimeMs();
4401
+ const detachedIdleMs = resolveRuntimeRotationAppHelperDetachedIdleMs();
3982
4402
  const ownerPid = resolveRuntimeRotationAppHelperOwnerPid();
4403
+ const isOwnerAlive = createRuntimeRotationAppHelperOwnerLivenessCheck(
4404
+ ownerPid,
4405
+ resolveRuntimeRotationAppHelperOwnerStartTimeMs(),
4406
+ resolveRuntimeRotationAppHelperOwnerRecheckMs(idleTimeoutMs, detachedIdleMs),
4407
+ );
3983
4408
  let lastActivityAt = startedAt;
3984
4409
  let lastRequestCount = 0;
4410
+ let lastPublishedToken = null;
4411
+ let lastPublishedAt = 0;
4412
+ // When the owner was first confirmed dead. Null while it is alive, and reset
4413
+ // to null if a later probe revives the verdict, so a transient "dead" cannot
4414
+ // ratchet the detached deadline the way the old liveness check ratcheted the
4415
+ // idle one.
4416
+ let ownerGoneSince = null;
4417
+ // A detached consumer holding a socket is evidence the handoff was real,
4418
+ // so it blocks the detached reap even with no requests in flight. The
4419
+ // absence of that evidence is not evidence of absence, and this fails
4420
+ // open on purpose: a proxy that cannot report connections reads as zero,
4421
+ // so the detached window still reaps it on activity alone. Erring the
4422
+ // other way — treating "unknown" as "someone is attached" — would restore
4423
+ // the leak for any shape that stopped answering.
4424
+ const countOpenConnections = () => {
4425
+ if (typeof proxyServer?.getOpenConnectionCount !== "function") return 0;
4426
+ const open = proxyServer.getOpenConnectionCount();
4427
+ // Only a positive count of sockets is evidence of a consumer. Anything
4428
+ // else — negative, fractional, NaN, Infinity — is "unknown", and unknown
4429
+ // degrades exactly the way a missing method does. Comparing a garbage
4430
+ // reading against 0 directly would block the reap forever and silently
4431
+ // restore the leak this exists to close, which is the one direction the
4432
+ // fix cannot afford to fail in.
4433
+ return Number.isSafeInteger(open) && open > 0 ? open : 0;
4434
+ };
4435
+ // The deadline the reaper will actually enforce, which is the earlier of the
4436
+ // idle timeout and — once the owner is gone — the detached window.
4437
+ const resolveIdleDeadline = () =>
4438
+ ownerGoneSince !== null && detachedIdleMs > 0
4439
+ ? Math.min(
4440
+ lastActivityAt + idleTimeoutMs,
4441
+ Math.max(lastActivityAt, ownerGoneSince) + detachedIdleMs,
4442
+ )
4443
+ : lastActivityAt + idleTimeoutMs;
4444
+ // Freshness readers tolerate hours of staleness, but tests run the whole
4445
+ // lifecycle in milliseconds — heartbeat at least once per idle window.
4446
+ //
4447
+ // The detached window counts too. `publishToken` deliberately zeroes
4448
+ // `idleExpiresAt` so a deadline that moves every tick is not a reason to
4449
+ // rewrite the file, which means the published deadline only catches up on a
4450
+ // heartbeat. Once the owner dies the real deadline collapses from the idle
4451
+ // timeout to the detached window, so a heartbeat pinned to the idle window
4452
+ // alone would leave `rotation status` advertising a 12-hour deadline for a
4453
+ // helper that is seconds from exiting — worse under a short
4454
+ // CODEX_MULTI_AUTH_APP_ROTATION_DETACHED_IDLE_MS override, where the helper
4455
+ // can vanish before the file is ever corrected.
4456
+ const statusHeartbeatMs = Math.min(
4457
+ APP_RUNTIME_HELPER_STATUS_HEARTBEAT_MS,
4458
+ idleTimeoutMs,
4459
+ detachedIdleMs > 0 ? detachedIdleMs : Number.POSITIVE_INFINITY,
4460
+ );
3985
4461
 
3986
- const publishStatus = (state) => {
3987
- writeRuntimeRotationAppHelperStatus(
3988
- createRuntimeRotationAppHelperStatus({
3989
- proxyServer,
3990
- startedAt,
3991
- identityToken,
3992
- idleTimeoutMs,
3993
- lastActivityAt,
3994
- state,
3995
- }),
3996
- );
4462
+ const publishStatus = (state, { force = false } = {}) => {
4463
+ const payload = createRuntimeRotationAppHelperStatus({
4464
+ proxyServer,
4465
+ startedAt,
4466
+ identityToken,
4467
+ idleTimeoutMs,
4468
+ lastActivityAt,
4469
+ idleExpiresAt: resolveIdleDeadline(),
4470
+ state,
4471
+ });
4472
+ // `updatedAt` moves every call and `idleExpiresAt` moves every tick the
4473
+ // owner is alive; neither is a reason to rewrite the file. Everything
4474
+ // else changing — state, traffic counters, account fields — is.
4475
+ const publishToken = JSON.stringify({
4476
+ ...payload,
4477
+ updatedAt: 0,
4478
+ idleExpiresAt: 0,
4479
+ });
4480
+ const now = Date.now();
4481
+ if (
4482
+ !force &&
4483
+ publishToken === lastPublishedToken &&
4484
+ now - lastPublishedAt < statusHeartbeatMs
4485
+ ) {
4486
+ return;
4487
+ }
4488
+ lastPublishedToken = publishToken;
4489
+ lastPublishedAt = now;
4490
+ writeRuntimeRotationAppHelperStatus(payload);
3997
4491
  };
3998
4492
 
3999
4493
  const cleanup = async (state = "stopped") => {
@@ -4016,7 +4510,11 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
4016
4510
  try {
4017
4511
  await proxyServer?.close?.();
4018
4512
  } finally {
4019
- publishStatus(state);
4513
+ // The terminal stamp is always written; the next launcher's sweep
4514
+ // removes it once this PID is dead. The owner file has no
4515
+ // post-mortem value, so it goes now.
4516
+ publishStatus(state, { force: true });
4517
+ removeRuntimeRotationAppHelperOwnerFile(process.env, process.pid);
4020
4518
  }
4021
4519
  }
4022
4520
  };
@@ -4058,17 +4556,30 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
4058
4556
  clientApiKey,
4059
4557
  configTomlModule,
4060
4558
  );
4061
- const appServerConfigArgs = useCanonicalHome
4062
- ? [
4063
- ...(runtimeContext.args ?? []),
4064
- "-c",
4065
- `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`,
4066
- ]
4067
- : [];
4068
- appServerShimDir = installRuntimeRotationAppServerCliShim(
4069
- runtimeContext.env,
4070
- appServerConfigArgs,
4071
- );
4559
+ // The shim only exists so a Codex process that spawns its own
4560
+ // `codex app-server` through `CODEX_CLI_PATH` — the desktop app — gets that
4561
+ // spawn routed back through the wrapper. Installing it is not free and not
4562
+ // inert: it copies the node image into a per-pid directory and stamps
4563
+ // `CODEX_CLI_PATH`, `NODE_OPTIONS` and `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`
4564
+ // onto the environment every forwarded child inherits. A wrapper-invoked
4565
+ // `app-server` is already launched with the rotation overrides on its own
4566
+ // command line, so it must not pay for or inherit any of that (#659).
4567
+ const installAppServerShim =
4568
+ (process.env[APP_RUNTIME_HELPER_INSTALL_APP_SERVER_SHIM_ENV] ?? "1").trim() !==
4569
+ "0";
4570
+ if (installAppServerShim) {
4571
+ const appServerConfigArgs = useCanonicalHome
4572
+ ? [
4573
+ ...(runtimeContext.args ?? []),
4574
+ "-c",
4575
+ `model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`,
4576
+ ]
4577
+ : [];
4578
+ appServerShimDir = installRuntimeRotationAppServerCliShim(
4579
+ runtimeContext.env,
4580
+ appServerConfigArgs,
4581
+ );
4582
+ }
4072
4583
  lastRequestCount = proxyServer.getStatus?.().totalRequests ?? 0;
4073
4584
  publishStatus("running");
4074
4585
  process.stdout.write(
@@ -4076,7 +4587,10 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
4076
4587
  type: "ready",
4077
4588
  pid: process.pid,
4078
4589
  baseUrl: proxyServer.baseUrl,
4079
- statusPath: resolveRuntimeRotationAppHelperStatusPath(),
4590
+ statusPath: resolveRuntimeRotationAppHelperStatusPath(
4591
+ process.env,
4592
+ process.pid,
4593
+ ),
4080
4594
  args: runtimeContext.args ?? [],
4081
4595
  env: pickRuntimeRotationAppHelperEnv(runtimeContext.env),
4082
4596
  })}\n`,
@@ -4089,14 +4603,51 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
4089
4603
  lastRequestCount = requestCount;
4090
4604
  lastActivityAt = currentTime;
4091
4605
  }
4092
- if (ownerPid && isRuntimeRotationAppHelperOwnerAlive(ownerPid)) {
4606
+ const ownerVerdict = isOwnerAlive(currentTime);
4607
+ if (ownerVerdict === OWNER_ALIVE) {
4093
4608
  lastActivityAt = currentTime;
4609
+ ownerGoneSince = null;
4610
+ } else if (ownerVerdict === OWNER_DEAD && ownerGoneSince === null) {
4611
+ ownerGoneSince = currentTime;
4094
4612
  }
4095
4613
  publishStatus("running");
4096
4614
  if (currentTime - lastActivityAt >= idleTimeoutMs) {
4097
4615
  exitAfterCleanup("idle-timeout", 0);
4616
+ } else if (
4617
+ ownerGoneSince !== null &&
4618
+ detachedIdleMs > 0 &&
4619
+ currentTime >= resolveIdleDeadline() &&
4620
+ requestCount === 0 &&
4621
+ countOpenConnections() === 0
4622
+ ) {
4623
+ // The launcher is gone, nothing is connected, nothing has been
4624
+ // proxied for the detached window, and nothing has *ever* been
4625
+ // proxied: this helper was stranded by a launcher that exited, not
4626
+ // handed to a consumer that wants it.
4627
+ //
4628
+ // The never-served gate is what separates the two. An open socket
4629
+ // is not a durable signal — the proxy leaves `keepAliveTimeout` at
4630
+ // Node's 5s default, so a `codex app` session that is merely idle
4631
+ // between turns has zero sockets within seconds, and the socket
4632
+ // check alone would reap the live proxy out from under the desktop
4633
+ // app after the detached window. A helper that has served even one
4634
+ // request was genuinely handed off; from then on the idle timeout
4635
+ // and the lifetime ceiling bound it, exactly as they did before the
4636
+ // detached reap existed.
4637
+ exitAfterCleanup("owner-gone", 0);
4638
+ } else if (
4639
+ maxLifetimeMs > 0 &&
4640
+ currentTime - startedAt >= maxLifetimeMs
4641
+ ) {
4642
+ // The ceiling is deliberately unconditional on activity: it exists
4643
+ // for exactly the case where activity accounting is wrong.
4644
+ exitAfterCleanup("max-lifetime", 0);
4098
4645
  }
4099
- }, Math.min(1_000, Math.max(50, Math.floor(idleTimeoutMs / 2))));
4646
+ // Tick against the shortest window that can fire, so a short detached
4647
+ // window is enforced at its own resolution rather than the idle
4648
+ // timeout's. Both defaults are far above 2s, so production still ticks
4649
+ // once a second.
4650
+ }, resolveRuntimeRotationAppHelperTickMs(idleTimeoutMs, detachedIdleMs));
4100
4651
  } catch (error) {
4101
4652
  process.stdout.write(
4102
4653
  `${JSON.stringify({
@@ -4184,16 +4735,30 @@ function startRuntimeRotationAppHelper(baseContext, options = {}) {
4184
4735
  let stderrBuffer = "";
4185
4736
  let settled = false;
4186
4737
  const identityToken = randomBytes(24).toString("hex");
4738
+ // The launcher states its own identity — PID plus kernel start time — so
4739
+ // the helper's owner-liveness check can tell "my launcher" from a later
4740
+ // process that recycled the PID. An empty value (no `ps` on this
4741
+ // platform) leaves the helper on the bare liveness check.
4742
+ const launcherStartTimeMs = readProcessStartTimeMs(process.pid);
4187
4743
  const helperEnv = {
4188
4744
  ...baseContext.env,
4189
4745
  CODEX_MULTI_AUTH_DIR: resolveRuntimeRotationOriginalMultiAuthDir(
4190
4746
  realCodexHome,
4191
4747
  baseContext.env,
4192
4748
  ),
4749
+ // PID and start time are two halves of one identity and must describe
4750
+ // the same process: both always come from this launcher's own capture,
4751
+ // never from an inherited environment value, which would marry this
4752
+ // PID to another process's start time and make the helper declare its
4753
+ // live owner dead at the first recheck.
4193
4754
  [APP_RUNTIME_HELPER_OWNER_PID_ENV]: String(process.pid),
4755
+ [APP_RUNTIME_HELPER_OWNER_START_TIME_ENV]:
4756
+ launcherStartTimeMs !== null ? String(launcherStartTimeMs) : "",
4194
4757
  [APP_RUNTIME_HELPER_REAL_CODEX_HOME_ENV]: realCodexHome,
4195
4758
  [APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV]:
4196
4759
  options.useCanonicalHome === true ? "1" : "0",
4760
+ [APP_RUNTIME_HELPER_INSTALL_APP_SERVER_SHIM_ENV]:
4761
+ options.installAppServerShim === false ? "0" : "1",
4197
4762
  };
4198
4763
  const helper = spawn(
4199
4764
  process.execPath,
@@ -4209,6 +4774,14 @@ function startRuntimeRotationAppHelper(baseContext, options = {}) {
4209
4774
  },
4210
4775
  );
4211
4776
  writeRuntimeRotationAppHelperOwner(identityToken, helper.pid, helperEnv);
4777
+ // Swept after the spawn, never before it. The sweep is synchronous and
4778
+ // unbounded — a readdir, a readFileSync and an rmSync per candidate, plus
4779
+ // bounded `ps` probes — and the state it exists to clean up (hundreds of
4780
+ // stale files, a loaded process table) is exactly the state that makes it
4781
+ // slow. Running it first put all of that latency in front of `codex app`
4782
+ // and TUI startup; running it here lets the helper boot in parallel with
4783
+ // it. Nothing about spawning depends on the sweep having finished.
4784
+ sweepStaleRuntimeRotationAppHelperMetadata(helperEnv);
4212
4785
  let timeout = null;
4213
4786
  const finish = (result) => {
4214
4787
  if (settled) return;
@@ -4227,6 +4800,12 @@ function startRuntimeRotationAppHelper(baseContext, options = {}) {
4227
4800
  }, APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS);
4228
4801
  helper.stdout?.setEncoding("utf8");
4229
4802
  helper.stdout?.on("data", (chunk) => {
4803
+ // Keep consuming after startup settles so the helper never blocks on a
4804
+ // full pipe, but stop accumulating: `app-server` holds this parent open
4805
+ // for the resident server's whole life, so a buffer that only ever grows
4806
+ // is a leak, and re-parsing the retained ready line on every later chunk
4807
+ // is pure waste.
4808
+ if (settled) return;
4230
4809
  stdoutBuffer += chunk;
4231
4810
  const newlineIndex = stdoutBuffer.indexOf("\n");
4232
4811
  if (newlineIndex < 0) return;
@@ -4249,6 +4828,7 @@ function startRuntimeRotationAppHelper(baseContext, options = {}) {
4249
4828
  });
4250
4829
  helper.stderr?.setEncoding("utf8");
4251
4830
  helper.stderr?.on("data", (chunk) => {
4831
+ if (settled) return;
4252
4832
  stderrBuffer += chunk;
4253
4833
  });
4254
4834
  helper.once("error", fail);
@@ -4268,11 +4848,17 @@ async function createRuntimeRotationAppHelperContext(
4268
4848
  configTomlModule,
4269
4849
  options = {},
4270
4850
  ) {
4271
- const startedAt = Date.now();
4272
4851
  const { helper, message } = await startRuntimeRotationAppHelper(
4273
4852
  baseContext,
4274
4853
  options,
4275
4854
  );
4855
+ // Start the grace clock once the helper is ready, not before it is launched.
4856
+ // Launching is bounded by APP_RUNTIME_HELPER_LAUNCH_TIMEOUT_MS (15s), so a
4857
+ // cold or loaded machine could otherwise burn the whole 5s window before the
4858
+ // forwarded command had run at all, and `codex app` — which relies on the
4859
+ // window rather than an explicit `detachOnExit` — would kill the helper it
4860
+ // had just handed the desktop app off to.
4861
+ const startedAt = Date.now();
4276
4862
  const helperEnv = message.env ?? {};
4277
4863
  const helperShimDir =
4278
4864
  typeof helperEnv.CODEX_CLI_PATH === "string" &&
@@ -4284,9 +4870,16 @@ async function createRuntimeRotationAppHelperContext(
4284
4870
  : [];
4285
4871
  const detachGraceMs = resolveRuntimeRotationAppHelperDetachGraceMs(baseContext.env);
4286
4872
 
4873
+ // An explicit `detachOnExit: false` is a decision, not a default: a resident
4874
+ // server owns its proxy for its whole lifetime, and leaving the helper behind
4875
+ // on a short-lived run — a client attaches, runs one query, disconnects —
4876
+ // strands it for the full idle timeout with nobody left to refresh its
4877
+ // activity clock. Only an unset `detachOnExit` falls back to the grace window.
4287
4878
  const cleanup = async ({ exitCode } = {}) => {
4288
4879
  const livedMs = Date.now() - startedAt;
4289
- if (exitCode === 0 && (options.detachOnExit === true || livedMs < detachGraceMs)) {
4880
+ const detachWithinGrace =
4881
+ options.detachOnExit !== false && livedMs < detachGraceMs;
4882
+ if (exitCode === 0 && (options.detachOnExit === true || detachWithinGrace)) {
4290
4883
  helper.stdout?.destroy();
4291
4884
  helper.stderr?.destroy();
4292
4885
  helper.unref();
@@ -4321,6 +4914,11 @@ async function createRuntimeRotationAppHelperContext(
4321
4914
  ...baseContext.env,
4322
4915
  ...helperEnv,
4323
4916
  },
4917
+ // Without the app-server shim there is no `CODEX_MULTI_AUTH_APP_SERVER_ACCOUNT_LABEL`
4918
+ // in the forwarded environment, so the caller has to ask for the
4919
+ // `account/read` / `getAuthStatus` / `account/rateLimits/read` rewriting
4920
+ // explicitly. Only the shadow branch used to set it.
4921
+ proxyAppServerAccountRead: options.proxyAppServerAccountRead === true,
4324
4922
  cleanup: async (details) => {
4325
4923
  try {
4326
4924
  await cleanup(details);
@@ -4348,14 +4946,56 @@ async function createRuntimeRotationProxyContextIfEnabled(
4348
4946
  return baseContext;
4349
4947
  }
4350
4948
 
4949
+ // A helper that cannot start is a hard failure for these branches — unlike the
4950
+ // shadow path below, there is no rotation-off shape left to degrade into. Turn
4951
+ // it into a diagnostic and a nonzero exit rather than an unhandled rejection,
4952
+ // and release the compatibility shadow home the caller already built.
4953
+ const startAppHelperContext = async (options) => {
4954
+ try {
4955
+ return await createRuntimeRotationAppHelperContext(
4956
+ baseContext,
4957
+ configTomlModule,
4958
+ options,
4959
+ );
4960
+ } catch (error) {
4961
+ try {
4962
+ baseContext.cleanup?.();
4963
+ } catch {
4964
+ // Best-effort cleanup only; report the original failure.
4965
+ }
4966
+ return {
4967
+ startupError: `codex-multi-auth runtime rotation helper failed to start: ${
4968
+ error instanceof Error ? error.message : String(error)
4969
+ }`,
4970
+ };
4971
+ }
4972
+ };
4973
+
4974
+ // `app-server` is a resident process, and the shadow home cannot host one: the
4975
+ // mirror links directories, so Codex's lstat-strict check on
4976
+ // `<CODEX_HOME>/app-server-control` refuses to start, and a server that does
4977
+ // start serves every attached client a frozen snapshot of the thread index for
4978
+ // its whole life. Same reasoning that moved `resume`/`fork` in #648 (#659).
4979
+ if (isCodexAppServerCommand(rawArgs)) {
4980
+ return startAppHelperContext({
4981
+ // A resident server owns its proxy for its whole lifetime.
4982
+ detachOnExit: false,
4983
+ useCanonicalHome: true,
4984
+ // Nothing here spawns `codex app-server` through `CODEX_CLI_PATH`; the
4985
+ // server is launched with the rotation overrides already on its command
4986
+ // line. See the shim gate in `runRuntimeRotationAppHelper`.
4987
+ installAppServerShim: false,
4988
+ proxyAppServerAccountRead: true,
4989
+ });
4990
+ }
4351
4991
  if (isCodexAppCommand(rawArgs)) {
4352
- return createRuntimeRotationAppHelperContext(baseContext, configTomlModule);
4992
+ return startAppHelperContext();
4353
4993
  }
4354
4994
  if (
4355
4995
  isCodexInteractiveTuiCommand(rawArgs) ||
4356
4996
  isCodexInteractiveResumeCommand(rawArgs)
4357
4997
  ) {
4358
- return createRuntimeRotationAppHelperContext(baseContext, configTomlModule, {
4998
+ return startAppHelperContext({
4359
4999
  detachOnExit: true,
4360
5000
  useCanonicalHome: true,
4361
5001
  });