Use app-server auth status for Codex readiness (#177)

* Use app-server auth status for Codex readiness

* fix: reuse existing app server for auth checks
This commit is contained in:
Dominik Kundel 2026-04-07 20:06:22 -07:00 committed by GitHub
parent f17e7f8486
commit 62c351a7bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 423 additions and 58 deletions

View File

@ -11,8 +11,8 @@ import {
buildPersistentTaskThreadName,
DEFAULT_CONTINUE_PROMPT,
findLatestTaskThread,
getCodexAuthStatus,
getCodexAvailability,
getCodexLoginStatus,
getSessionRuntimeStatus,
interruptAppServerTurn,
parseStructuredOutput,
@ -176,19 +176,19 @@ function firstMeaningfulLine(text, fallback) {
return line ?? fallback;
}
function buildSetupReport(cwd, actionsTaken = []) {
async function buildSetupReport(cwd, actionsTaken = []) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
const codexStatus = getCodexAvailability(cwd);
const authStatus = getCodexLoginStatus(cwd);
const authStatus = await getCodexAuthStatus(cwd);
const config = getConfig(workspaceRoot);
const nextSteps = [];
if (!codexStatus.available) {
nextSteps.push("Install Codex with `npm install -g @openai/codex`.");
}
if (codexStatus.available && !authStatus.loggedIn) {
if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) {
nextSteps.push("Run `!codex login`.");
nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`.");
}
@ -209,7 +209,7 @@ function buildSetupReport(cwd, actionsTaken = []) {
};
}
function handleSetup(argv) {
async function handleSetup(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd"],
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
@ -231,7 +231,7 @@ function handleSetup(argv) {
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
}
const finalReport = buildSetupReport(cwd, actionsTaken);
const finalReport = await buildSetupReport(cwd, actionsTaken);
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
}
@ -245,14 +245,11 @@ function buildAdversarialReviewPrompt(context, focusText) {
});
}
function ensureCodexReady(cwd) {
const authStatus = getCodexLoginStatus(cwd);
if (!authStatus.available) {
function ensureCodexAvailable(cwd) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
}
if (!authStatus.loggedIn) {
throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry.");
}
}
function buildNativeReviewTarget(target) {
@ -325,7 +322,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) {
}
async function executeReviewRun(request) {
ensureCodexReady(request.cwd);
ensureCodexAvailable(request.cwd);
ensureGitRepository(request.cwd);
const target = resolveReviewTarget(request.cwd, {
@ -429,7 +426,7 @@ async function executeReviewRun(request) {
async function executeTaskRun(request) {
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
ensureCodexReady(request.cwd);
ensureCodexAvailable(request.cwd);
const taskMetadata = buildTaskRunMetadata({
prompt: request.prompt,
@ -728,7 +725,7 @@ async function handleTask(argv) {
});
if (options.background) {
ensureCodexReady(cwd);
ensureCodexAvailable(cwd);
requireTaskRequest(prompt, resumeLast);
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
@ -967,7 +964,7 @@ async function main() {
switch (subcommand) {
case "setup":
handleSetup(argv);
await handleSetup(argv);
break;
case "review":
await handleReview(argv);

View File

@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
capabilities?: InitializeCapabilities;
brokerEndpoint?: string;
disableBroker?: boolean;
reuseExistingBroker?: boolean;
}
export interface AppServerMethodMap {

View File

@ -13,7 +13,7 @@ import process from "node:process";
import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
import { terminateProcessTree } from "./process.mjs";
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
@ -333,7 +333,10 @@ export class CodexAppServerClient {
let brokerEndpoint = null;
if (!options.disableBroker) {
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
if (!brokerEndpoint) {
if (!brokerEndpoint && options.reuseExistingBroker) {
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
}
if (!brokerEndpoint && !options.reuseExistingBroker) {
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
brokerEndpoint = brokerSession?.endpoint ?? null;
}

View File

@ -37,7 +37,7 @@
import { readJsonFile } from "./fs.mjs";
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
import { loadBrokerSession } from "./broker-lifecycle.mjs";
import { binaryAvailable, runCommand } from "./process.mjs";
import { binaryAvailable } from "./process.mjs";
const SERVICE_NAME = "claude_code_codex_plugin";
const TASK_THREAD_PREFIX = "Codex Companion Task";
@ -652,6 +652,134 @@ function buildResultStatus(turnState) {
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}
const BUILTIN_PROVIDER_LABELS = new Map([
["openai", "OpenAI"],
["ollama", "Ollama"],
["lmstudio", "LM Studio"]
]);
function normalizeProviderId(value) {
const providerId = typeof value === "string" ? value.trim() : "";
return providerId || null;
}
function formatProviderLabel(providerId, providerConfig = null) {
const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : "";
if (configuredName) {
return configuredName;
}
if (!providerId) {
return "The active provider";
}
return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId;
}
function buildAuthStatus(fields = {}) {
return {
available: true,
loggedIn: false,
detail: "not authenticated",
source: "unknown",
authMethod: null,
verified: null,
requiresOpenaiAuth: null,
provider: null,
...fields
};
}
function resolveProviderConfig(configResponse) {
const config = configResponse?.config;
if (!config || typeof config !== "object") {
return {
providerId: null,
providerConfig: null
};
}
const providerId = normalizeProviderId(config.model_provider);
const providers =
config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers)
? config.model_providers
: null;
const providerConfig =
providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
return {
providerId,
providerConfig
};
}
function buildAppServerAuthStatus(accountResponse, configResponse) {
const account = accountResponse?.account ?? null;
const requiresOpenaiAuth =
typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null;
const { providerId, providerConfig } = resolveProviderConfig(configResponse);
const providerLabel = formatProviderLabel(providerId, providerConfig);
if (account?.type === "chatgpt") {
const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null;
return buildAuthStatus({
loggedIn: true,
detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active",
source: "app-server",
authMethod: "chatgpt",
verified: true,
requiresOpenaiAuth,
provider: providerId
});
}
if (account?.type === "apiKey") {
return buildAuthStatus({
loggedIn: true,
detail: "API key configured (unverified)",
source: "app-server",
authMethod: "apiKey",
verified: false,
requiresOpenaiAuth,
provider: providerId
});
}
if (requiresOpenaiAuth === false) {
return buildAuthStatus({
loggedIn: true,
detail: `${providerLabel} is configured and does not require OpenAI authentication`,
source: "app-server",
requiresOpenaiAuth,
provider: providerId
});
}
return buildAuthStatus({
loggedIn: false,
detail: `${providerLabel} requires OpenAI authentication`,
source: "app-server",
requiresOpenaiAuth,
provider: providerId
});
}
async function getCodexAuthStatusFromClient(client, cwd) {
try {
const accountResponse = await client.request("account/read", { refreshToken: false });
const configResponse = await client.request("config/read", {
includeLayers: false,
cwd
});
return buildAppServerAuthStatus(accountResponse, configResponse);
} catch (error) {
return buildAuthStatus({
loggedIn: false,
detail: error instanceof Error ? error.message : String(error),
source: "app-server"
});
}
}
export function getCodexAvailability(cwd) {
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
if (!versionStatus.available) {
@ -691,38 +819,39 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
};
}
export function getCodexLoginStatus(cwd) {
export async function getCodexAuthStatus(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
return {
available: false,
loggedIn: false,
detail: availability.detail
detail: availability.detail,
source: "availability",
authMethod: null,
verified: null,
requiresOpenaiAuth: null,
provider: null
};
}
const result = runCommand("codex", ["login", "status"], { cwd });
if (result.error) {
return {
available: true,
let client = null;
try {
client = await CodexAppServerClient.connect(cwd, {
env: options.env,
reuseExistingBroker: true
});
return await getCodexAuthStatusFromClient(client, cwd);
} catch (error) {
return buildAuthStatus({
loggedIn: false,
detail: result.error.message
};
detail: error instanceof Error ? error.message : String(error),
source: "app-server"
});
} finally {
if (client) {
await client.close().catch(() => {});
}
}
if (result.status === 0) {
return {
available: true,
loggedIn: true,
detail: result.stdout.trim() || "authenticated"
};
}
return {
available: true,
loggedIn: false,
detail: result.stderr.trim() || result.stdout.trim() || "not authenticated"
};
}
export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
@ -745,12 +874,9 @@ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
};
}
const brokerEndpoint = process.env[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
let client = null;
try {
client = brokerEndpoint
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
await client.request("turn/interrupt", { threadId, turnId });
return {
attempted: true,

View File

@ -6,7 +6,7 @@ import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { getCodexLoginStatus } from "./lib/codex.mjs";
import { getCodexAvailability } from "./lib/codex.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import { getConfig, listJobs } from "./lib/state.mjs";
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) {
}
function buildSetupNote(cwd) {
const authStatus = getCodexLoginStatus(cwd);
if (authStatus.available && authStatus.loggedIn) {
const availability = getCodexAvailability(cwd);
if (availability.available) {
return null;
}
const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
const detail = availability.detail ? ` ${availability.detail}.` : "";
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
}
function parseStopReviewOutput(rawOutput) {
@ -175,4 +175,10 @@ function main() {
logNote(runningTaskNote);
}
main();
try {
main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
}

View File

@ -63,6 +63,54 @@ function buildTurn(id, status = "inProgress", error = null) {
return { id, status, items: [], error };
}
function buildAccountReadResult() {
switch (BEHAVIOR) {
case "logged-out":
case "refreshable-auth":
case "auth-run-fails":
return { account: null, requiresOpenaiAuth: true };
case "provider-no-auth":
case "env-key-provider":
return { account: null, requiresOpenaiAuth: false };
case "api-key-account-only":
return { account: { type: "apiKey" }, requiresOpenaiAuth: true };
default:
return {
account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
requiresOpenaiAuth: true
};
}
}
function buildConfigReadResult() {
switch (BEHAVIOR) {
case "provider-no-auth":
return {
config: { model_provider: "ollama" },
origins: {}
};
case "env-key-provider":
return {
config: {
model_provider: "openai-custom",
model_providers: {
"openai-custom": {
name: "OpenAI custom",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false
}
}
},
origins: {}
};
default:
return {
config: { model_provider: "openai" },
origins: {}
};
}
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
@ -193,7 +241,7 @@ if (args[0] === "app-server" && args[1] === "--help") {
process.exit(0);
}
if (args[0] === "login" && args[1] === "status") {
if (BEHAVIOR === "logged-out") {
if (BEHAVIOR === "logged-out" || BEHAVIOR === "refreshable-auth" || BEHAVIOR === "auth-run-fails" || BEHAVIOR === "provider-no-auth" || BEHAVIOR === "env-key-provider" || BEHAVIOR === "api-key-account-only") {
console.error("not authenticated");
process.exit(1);
}
@ -230,7 +278,21 @@ rl.on("line", (line) => {
case "initialized":
break;
case "account/read":
send({ id: message.id, result: buildAccountReadResult() });
break;
case "config/read":
if (BEHAVIOR === "config-read-fails") {
throw new Error("config/read failed for cwd");
}
send({ id: message.id, result: buildConfigReadResult() });
break;
case "thread/start": {
if (BEHAVIOR === "auth-run-fails") {
throw new Error("authentication expired; run codex login");
}
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
}

View File

@ -65,6 +65,77 @@ test("setup is ready without npm when Codex is already installed and authenticat
assert.equal(payload.auth.loggedIn, true);
});
test("setup trusts app-server API key auth even when login status alone would fail", () => {
const binDir = makeTempDir();
installFakeCodex(binDir, "api-key-account-only");
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: ROOT,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.ready, true);
assert.equal(payload.auth.loggedIn, true);
assert.equal(payload.auth.authMethod, "apiKey");
assert.equal(payload.auth.source, "app-server");
assert.match(payload.auth.detail, /API key configured \(unverified\)/);
});
test("setup is ready when the active provider does not require OpenAI login", () => {
const binDir = makeTempDir();
installFakeCodex(binDir, "provider-no-auth");
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: ROOT,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.ready, true);
assert.equal(payload.auth.loggedIn, true);
assert.equal(payload.auth.authMethod, null);
assert.equal(payload.auth.source, "app-server");
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
});
test("setup treats custom providers with app-server-ready config as ready", () => {
const binDir = makeTempDir();
installFakeCodex(binDir, "env-key-provider");
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: ROOT,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.ready, true);
assert.equal(payload.auth.loggedIn, true);
assert.equal(payload.auth.authMethod, null);
assert.equal(payload.auth.source, "app-server");
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
});
test("setup reports not ready when app-server config read fails", () => {
const binDir = makeTempDir();
installFakeCodex(binDir, "config-read-fails");
const result = run("node", [SCRIPT, "setup", "--json"], {
cwd: ROOT,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
const payload = JSON.parse(result.stdout);
assert.equal(payload.ready, false);
assert.equal(payload.auth.loggedIn, false);
assert.equal(payload.auth.source, "app-server");
assert.match(payload.auth.detail, /config\/read failed for cwd/);
});
test("review renders a no-findings result from app-server review/start", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
@ -86,6 +157,60 @@ test("review renders a no-findings result from app-server review/start", () => {
assert.match(result.stdout, /No material issues found/);
});
test("task runs when the active provider does not require OpenAI login", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "provider-no-auth");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
const result = run("node", [SCRIPT, "task", "check auth preflight"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Handled the requested task/);
});
test("task runs without auth preflight so Codex can refresh an expired session", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "refreshable-auth");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
const result = run("node", [SCRIPT, "task", "check refreshable auth"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Handled the requested task/);
});
test("task reports the actual Codex auth error when the run is rejected", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "auth-run-fails");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
const result = run("node", [SCRIPT, "task", "check failed auth"], {
cwd: repo,
env: buildEnv(binDir)
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /authentication expired; run codex login/);
});
test("review accepts the quoted raw argument style for built-in base-branch review", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
@ -1598,10 +1723,10 @@ test("stop hook does not block when Codex is unavailable even if the review gate
assert.match(allowed.stderr, /Run \/codex:setup/i);
});
test("stop hook does not block when Codex is not authenticated even if the review gate is enabled", () => {
test("stop hook runs the actual task when auth status looks stale", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
installFakeCodex(binDir, "logged-out");
installFakeCodex(binDir, "refreshable-auth");
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
@ -1620,10 +1745,10 @@ test("stop hook does not block when Codex is not authenticated even if the revie
});
assert.equal(allowed.status, 0, allowed.stderr);
assert.equal(allowed.stdout.trim(), "");
assert.match(allowed.stderr, /Codex is not set up for the review gate/i);
assert.match(allowed.stderr, /not authenticated/i);
assert.match(allowed.stderr, /!codex login/i);
assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i);
const payload = JSON.parse(allowed.stdout);
assert.equal(payload.decision, "block");
assert.match(payload.reason, /Missing empty-state guard/i);
});
test("commands lazily start and reuse one shared app-server after first use", async () => {
@ -1671,6 +1796,51 @@ test("commands lazily start and reuse one shared app-server after first use", as
assert.equal(cleanup.status, 0, cleanup.stderr);
});
test("setup reuses an existing shared app-server without starting another one", () => {
const repo = makeTempDir();
const binDir = makeTempDir();
const fakeStatePath = path.join(binDir, "fake-codex-state.json");
installFakeCodex(binDir);
initGitRepo(repo);
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
run("git", ["add", "README.md"], { cwd: repo });
run("git", ["commit", "-m", "init"], { cwd: repo });
fs.writeFileSync(path.join(repo, "README.md"), "hello again\n");
const env = buildEnv(binDir);
const review = run("node", [SCRIPT, "review"], {
cwd: repo,
env
});
assert.equal(review.status, 0, review.stderr);
const brokerSession = loadBrokerSession(repo);
if (!brokerSession) {
return;
}
const setup = run("node", [SCRIPT, "setup", "--json"], {
cwd: repo,
env
});
assert.equal(setup.status, 0, setup.stderr);
const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8"));
assert.equal(fakeState.appServerStarts, 1);
const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], {
cwd: repo,
env,
input: JSON.stringify({
hook_event_name: "SessionEnd",
cwd: repo
})
});
assert.equal(cleanup.status, 0, cleanup.stderr);
});
test("status reports shared session runtime when a lazy broker is active", () => {
const repo = makeTempDir();
const binDir = makeTempDir();