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/.codex-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/dist/lib/auth/auth.d.ts.map +1 -1
- package/dist/lib/auth/auth.js +8 -3
- package/dist/lib/codex-manager/commands/rotation.d.ts.map +1 -1
- package/dist/lib/codex-manager/commands/rotation.js +43 -20
- package/dist/lib/runtime/app-bind.d.ts +7 -0
- package/dist/lib/runtime/app-bind.d.ts.map +1 -1
- package/dist/lib/runtime/app-bind.js +174 -57
- package/dist/lib/runtime/app-helper-selection.d.ts +69 -0
- package/dist/lib/runtime/app-helper-selection.d.ts.map +1 -0
- package/dist/lib/runtime/app-helper-selection.js +100 -0
- package/dist/lib/runtime/rotation-server-types.d.ts +7 -0
- package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
- package/dist/lib/runtime/runtime-current-account.d.ts +3 -2
- package/dist/lib/runtime/runtime-current-account.d.ts.map +1 -1
- package/dist/lib/runtime/runtime-current-account.js +34 -26
- package/dist/lib/runtime-constants.d.ts +30 -0
- package/dist/lib/runtime-constants.d.ts.map +1 -1
- package/dist/lib/runtime-constants.js +53 -0
- package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
- package/dist/lib/runtime-rotation-proxy.js +4 -0
- package/package.json +1 -2
- package/scripts/codex.js +704 -64
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3859
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4992
|
+
return startAppHelperContext();
|
|
4353
4993
|
}
|
|
4354
4994
|
if (
|
|
4355
4995
|
isCodexInteractiveTuiCommand(rawArgs) ||
|
|
4356
4996
|
isCodexInteractiveResumeCommand(rawArgs)
|
|
4357
4997
|
) {
|
|
4358
|
-
return
|
|
4998
|
+
return startAppHelperContext({
|
|
4359
4999
|
detachOnExit: true,
|
|
4360
5000
|
useCanonicalHome: true,
|
|
4361
5001
|
});
|