Skip to content

Commit dc9768f

Browse files
authored
fix(selfhost): distinguish a stale codex-reviewer flag name from unconfigured and cool down its breaker (#7473)
The GITTENSORY_→LOOPOVER_ env rebrand (#5652) retired dual-read of GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER and hardcoded a strict LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER === "1" check. An operator whose .env still uses the legacy name silently reverts to fully-disabled and gets the exact same generic codex_credential_isolation_required error as someone who never opted in at all — no signal telling them to rename the var. Recognize the retired flag name in assertCodexCredentialIsolation (without honoring it) and throw an actionable "rename it to LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER" message, but only when the current flag isn't already correctly set so a mounted CODEX_HOME isn't mislabeled a rename problem. The message keeps the codex_credential_isolation_required prefix so the structural circuit breaker still recognizes it. Read via a constant rather than env.GITTENSORY_... so the retired name stays out of the generated self-host env reference. Also widen isStructuralProviderConfigError's regex to match codex_credential_isolation_required (bare or with a ": rename …" suffix). It's a deterministic config failure, so it now earns the hour-long structural cooldown instead of retrying every 60s — the flood pattern that breaker exists to prevent. Closes #7466 Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 582fc3a commit dc9768f

5 files changed

Lines changed: 88 additions & 9 deletions

File tree

scripts/branding-drift-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"src/review/repo-doc-render.ts": 2,
2626
"src/review/repo-skill-render.ts": 2,
2727
"src/review/selftune-wire.ts": 1,
28-
"src/selfhost/ai.ts": 6,
28+
"src/selfhost/ai.ts": 9,
2929
"src/selfhost/health.ts": 3,
3030
"src/selfhost/monitored-work.ts": 1,
3131
"src/selfhost/orb-collector.ts": 1,

src/selfhost/ai.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,12 +473,29 @@ export function subscriptionCliEnv(
473473
return child;
474474
}
475475

476+
// The pre-rebrand name of the unsafe-reviewer opt-in flag. #5652 retired dual-read of GITTENSORY_-prefixed vars
477+
// repo-wide, so an operator whose .env still uses this name silently reverts to fully-disabled. We deliberately do
478+
// NOT honor it (the rebrand is intentional), but we still recognize it here purely to emit an actionable "rename it"
479+
// error instead of the same generic message an operator who never configured anything at all would get. Accessed
480+
// via this constant (not `env.GITTENSORY_...`) so the retired name stays out of the generated self-host env
481+
// reference — it is not a var operators should configure, only one we detect to redirect them.
482+
const LEGACY_UNSAFE_CODEX_REVIEWER_FLAG = "GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER";
483+
476484
function assertCodexCredentialIsolation(env: Record<string, string | undefined>): void {
477485
// `codex exec` receives attacker-controlled PR title/body/diff text. Its read-only sandbox prevents writes, but not
478486
// reads, so a self-hosted OAuth home mounted into the same filesystem can be prompt-injected into public output.
479487
// Fail closed until Codex exposes a brokered credential mode that does not put auth.json in the review sandbox.
480488
// Strict "1"-only, matching health.ts's codexAuthReadinessProbe and this flag's narrow opt-in convention.
481489
if (env.CODEX_HOME || env.LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER !== "1") {
490+
// An operator still on the retired flag name gets a specific, actionable signal to rename it — but only when the
491+
// current flag isn't already correctly set (a mounted CODEX_HOME with a valid opt-in is a different failure and
492+
// must not be mislabeled a rename problem). Keep the `codex_credential_isolation_required` prefix so the
493+
// structural circuit breaker in ai-review.ts still recognizes this deterministic failure and backs off.
494+
if (env.LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER !== "1" && env[LEGACY_UNSAFE_CODEX_REVIEWER_FLAG] === "1") {
495+
throw new Error(
496+
`codex_credential_isolation_required: ${LEGACY_UNSAFE_CODEX_REVIEWER_FLAG} is set but was retired in #5652; rename it to LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER`,
497+
);
498+
}
482499
throw new Error("codex_credential_isolation_required");
483500
}
484501
}

src/services/ai-review.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,15 +1022,19 @@ export function isRateLimitError(error: unknown): boolean {
10221022
}
10231023

10241024
/** True for a provider's own STRUCTURAL misconfiguration signal (`src/selfhost/ai.ts`'s
1025-
* `codex_auth_not_configured` / `codex_no_auth` — a missing or expired credential file). Unlike a transient
1026-
* timeout or rate limit, this will fail identically on every future attempt until an operator re-runs
1027-
* `codex auth` -- confirmed live (GITTENSORY-K/8: 2094 + 544 events over 16 days from one unfixed
1028-
* misconfiguration, the credential file was never present the whole time). Mirrors
1029-
* {@link isSubscriptionCliTimeout}/{@link isRateLimitError}'s identical non-transient-error short-circuit.
1030-
* Exported so `src/selfhost/ai.ts`'s circuit breaker can give this failure class a much longer cooldown
1031-
* than a genuinely transient one. */
1025+
* `codex_auth_not_configured` / `codex_no_auth` — a missing or expired credential file — or
1026+
* `codex_credential_isolation_required` — the fail-closed opt-in guard, thrown either bare or with a
1027+
* `: rename …` detail suffix). Unlike a transient timeout or rate limit, these fail identically on every future
1028+
* attempt until an operator re-runs `codex auth` / fixes the opt-in flag -- confirmed live (GITTENSORY-K/8:
1029+
* 2094 + 544 events over 16 days from one unfixed misconfiguration, the credential file was never present the
1030+
* whole time). Mirrors {@link isSubscriptionCliTimeout}/{@link isRateLimitError}'s identical non-transient-error
1031+
* short-circuit. Exported so `src/selfhost/ai.ts`'s circuit breaker can give this failure class a much longer
1032+
* cooldown than a genuinely transient one. */
10321033
export function isStructuralProviderConfigError(error: unknown): boolean {
1033-
return error instanceof Error && /^codex_(?:auth_not_configured|no_auth):/.test(error.message);
1034+
return (
1035+
error instanceof Error &&
1036+
/^codex_(?:auth_not_configured|no_auth|credential_isolation_required)(?::|$)/.test(error.message)
1037+
);
10341038
}
10351039

10361040
/** Cap on the diagnostic prefix logged for an unparseable model response (#observability-unparseable) -- long

test/unit/ai-review.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3077,6 +3077,18 @@ describe("pure helpers", () => {
30773077
it("isStructuralProviderConfigError matches only codex's own structural-config error messages, not other Errors or non-Error throws (GITTENSORY-K/8)", () => {
30783078
expect(isStructuralProviderConfigError(new Error("codex_auth_not_configured: ~/.codex/auth.json not found"))).toBe(true);
30793079
expect(isStructuralProviderConfigError(new Error("codex_no_auth: auth.json missing or expired"))).toBe(true);
3080+
// The fail-closed credential-isolation guard is equally deterministic, thrown either bare (never opted in) or
3081+
// with a `: rename …` detail suffix (legacy flag name still set) -- both must earn the structural cooldown (#7466).
3082+
expect(isStructuralProviderConfigError(new Error("codex_credential_isolation_required"))).toBe(true);
3083+
expect(
3084+
isStructuralProviderConfigError(
3085+
new Error(
3086+
"codex_credential_isolation_required: GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER is set but was retired in #5652; rename it to LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER",
3087+
),
3088+
),
3089+
).toBe(true);
3090+
// Prefix-anchored, but must not match a longer look-alike token that merely starts with the same characters.
3091+
expect(isStructuralProviderConfigError(new Error("codex_no_auth_pending"))).toBe(false);
30803092
expect(isStructuralProviderConfigError(new Error("connection reset"))).toBe(false);
30813093
// Anchored ("^codex_...") -- a wrapped/rethrown message doesn't match, only the exact provider-level throw does.
30823094
expect(isStructuralProviderConfigError(new Error("wrapped: codex_auth_not_configured: nested"))).toBe(false);

test/unit/selfhost-ai.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,52 @@ describe("subscription CLI helpers + fail-safe", () => {
19451945
).rejects.toThrow(/codex_credential_isolation_required/);
19461946
});
19471947

1948+
it("credential isolation: an operator still on the retired GITTENSORY_ flag name gets an actionable rename error, not the generic one (#7466)", async () => {
1949+
const shouldNotSpawn: StubSpawn = async () => {
1950+
throw new Error("spawned");
1951+
};
1952+
// Legacy flag set (and the current LOOPOVER_ one absent) — the operator opted in under the pre-rebrand name and
1953+
// silently reverted to disabled. The error must name both the retired var and its replacement so it is fixable
1954+
// without reading the source, and must still carry the codex_credential_isolation_required prefix.
1955+
await expect(
1956+
createCodexAi(
1957+
{ GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1" },
1958+
shouldNotSpawn,
1959+
).run("gpt-5", { prompt: "x" }),
1960+
).rejects.toThrow(
1961+
/codex_credential_isolation_required: GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER is set but was retired in #5652; rename it to LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER/,
1962+
);
1963+
});
1964+
1965+
it("credential isolation: never-configured still throws the plain generic error, distinct from the legacy-rename one (#7466)", async () => {
1966+
const shouldNotSpawn: StubSpawn = async () => {
1967+
throw new Error("spawned");
1968+
};
1969+
// Neither the current nor the retired flag is set — this must stay the bare generic message so it is
1970+
// distinguishable from the legacy-rename case above.
1971+
await expect(createCodexAi({}, shouldNotSpawn).run("gpt-5", { prompt: "x" })).rejects.toThrow(
1972+
/^codex_credential_isolation_required$/,
1973+
);
1974+
});
1975+
1976+
it("credential isolation: a set CODEX_HOME with a valid opt-in is not mislabeled a legacy-rename problem even when the legacy flag is also present (#7466)", async () => {
1977+
const shouldNotSpawn: StubSpawn = async () => {
1978+
throw new Error("spawned");
1979+
};
1980+
// CODEX_HOME is mounted (a distinct failure) while the current opt-in is correctly "1" — the rename branch must
1981+
// not fire; the operator sees the generic isolation error, not a spurious "rename your flag" instruction.
1982+
await expect(
1983+
createCodexAi(
1984+
{
1985+
CODEX_HOME: "/home/node/.codex",
1986+
LOOPOVER_ENABLE_UNSAFE_CODEX_REVIEWER: "1",
1987+
GITTENSORY_ENABLE_UNSAFE_CODEX_REVIEWER: "1",
1988+
},
1989+
shouldNotSpawn,
1990+
).run("gpt-5", { prompt: "x" }),
1991+
).rejects.toThrow(/^codex_credential_isolation_required$/);
1992+
});
1993+
19481994
it("resolveCodexAuthPath: CODEX_HOME wins, else HOME/.codex, else ~/.codex", () => {
19491995
expect(resolveCodexAuthPath({ CODEX_HOME: "/data/codex", HOME: "/home/node" })).toBe(
19501996
"/data/codex/auth.json",

0 commit comments

Comments
 (0)