diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..21688ed0b 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -610,6 +610,14 @@ async function captureTurn(client, threadId, startRequest, options = {}) { } } +function shouldRetryDirectAppServer(error, { client = null, brokerRequested = false } = {}) { + const brokerAttempted = client?.transport === "broker" || brokerRequested; + return ( + brokerAttempted && + (error?.rpcCode === BROKER_BUSY_RPC_CODE || error?.code === "ENOENT" || error?.code === "ECONNREFUSED") + ); +} + async function withAppServer(cwd, fn) { let client = null; try { @@ -619,9 +627,7 @@ async function withAppServer(cwd, fn) { return result; } catch (error) { const brokerRequested = client?.transport === "broker" || Boolean(process.env[BROKER_ENDPOINT_ENV]); - const shouldRetryDirect = - (client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) || - (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED")); + const shouldRetryDirect = shouldRetryDirectAppServer(error, { client, brokerRequested }); if (client) { await client.close().catch(() => {}); @@ -866,21 +872,21 @@ function buildAppServerAuthStatus(accountResponse, configResponse) { } 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 - }); + 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" - }); - } + return buildAppServerAuthStatus(accountResponse, configResponse); +} + +function buildUnavailableAuthStatus(error) { + return buildAuthStatus({ + loggedIn: false, + detail: error instanceof Error ? error.message : String(error), + source: "app-server" + }); } export function getCodexAvailability(cwd) { @@ -937,6 +943,8 @@ export async function getCodexAuthStatus(cwd, options = {}) { }; } + const configuredBrokerEndpoint = options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null; + const brokerRequested = Boolean(configuredBrokerEndpoint || loadBrokerSession(cwd)?.endpoint); let client = null; try { client = await CodexAppServerClient.connect(cwd, { @@ -945,11 +953,25 @@ export async function getCodexAuthStatus(cwd, options = {}) { }); return await getCodexAuthStatusFromClient(client, cwd); } catch (error) { - return buildAuthStatus({ - loggedIn: false, - detail: error instanceof Error ? error.message : String(error), - source: "app-server" - }); + const shouldRetryDirect = shouldRetryDirectAppServer(error, { client, brokerRequested }); + if (client) { + await client.close().catch(() => {}); + client = null; + } + + if (!shouldRetryDirect) { + return buildUnavailableAuthStatus(error); + } + + try { + client = await CodexAppServerClient.connect(cwd, { + env: options.env, + disableBroker: true + }); + return await getCodexAuthStatusFromClient(client, cwd); + } catch (directError) { + return buildUnavailableAuthStatus(directError); + } } finally { if (client) { await client.close().catch(() => {}); diff --git a/tests/fake-auth-broker.mjs b/tests/fake-auth-broker.mjs new file mode 100644 index 000000000..bfb15e1fa --- /dev/null +++ b/tests/fake-auth-broker.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import net from "node:net"; +import process from "node:process"; + +import { parseBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; + +const args = process.argv.slice(2); +const options = {}; +for (let index = 0; index < args.length; index += 2) { + options[args[index]?.replace(/^--/, "")] = args[index + 1]; +} + +if (!options.endpoint || !options.state || !options.mode) { + throw new Error("Usage: node tests/fake-auth-broker.mjs --endpoint --state --mode "); +} + +const target = parseBrokerEndpoint(options.endpoint); +const state = { + ready: false, + connections: 0, + closedConnections: 0, + requests: [] +}; + +function saveState() { + fs.writeFileSync(options.state, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +function send(socket, message) { + socket.write(`${JSON.stringify(message)}\n`); +} + +const server = net.createServer((socket) => { + state.connections += 1; + saveState(); + socket.setEncoding("utf8"); + let buffer = ""; + + socket.on("data", (chunk) => { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + if (!line.trim()) { + continue; + } + + const message = JSON.parse(line); + if (message.id === undefined) { + continue; + } + state.requests.push(message.method); + saveState(); + + if (message.method === "initialize") { + send(socket, { id: message.id, result: { userAgent: "fake-auth-broker" } }); + } else if (options.mode === "busy") { + send(socket, { + id: message.id, + error: { code: -32001, message: "Shared Codex broker is busy." } + }); + } else { + send(socket, { + id: message.id, + error: { code: -32099, message: "Broker auth request failed." } + }); + } + } + }); + + socket.on("close", () => { + state.closedConnections += 1; + saveState(); + }); +}); + +function shutdown() { + server.close(() => { + if (target.kind === "unix" && fs.existsSync(target.path)) { + fs.unlinkSync(target.path); + } + process.exit(0); + }); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +server.listen(target.path, () => { + state.ready = true; + saveState(); +}); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..6460330ac 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -19,7 +19,7 @@ const readline = require("node:readline"); function loadState() { if (!fs.existsSync(STATE_PATH)) { - return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null }; + return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, appServerExits: 0, threads: [], capabilities: null, lastInterrupt: null }; } return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); } @@ -275,6 +275,11 @@ bootState.appServerStarts = (bootState.appServerStarts || 0) + 1; saveState(bootState); const rl = readline.createInterface({ input: process.stdin }); +rl.on("close", () => { + const state = loadState(); + state.appServerExits = (state.appServerExits || 0) + 1; + saveState(state); +}); rl.on("line", (line) => { if (!line.trim()) { return; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..bff6ddb97 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; +import { createBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-endpoint.mjs"; import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs"; @@ -15,6 +16,7 @@ const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex"); const SCRIPT = path.join(PLUGIN_ROOT, "scripts", "codex-companion.mjs"); const STOP_HOOK = path.join(PLUGIN_ROOT, "scripts", "stop-review-gate-hook.mjs"); const SESSION_HOOK = path.join(PLUGIN_ROOT, "scripts", "session-lifecycle-hook.mjs"); +const FAKE_AUTH_BROKER = path.join(ROOT, "tests", "fake-auth-broker.mjs"); async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) { const start = Date.now(); @@ -28,6 +30,147 @@ async function waitFor(predicate, { timeoutMs = 5000, intervalMs = 50 } = {}) { throw new Error("Timed out waiting for condition."); } +async function startFakeAuthBroker(t, mode = "busy") { + const sessionDir = makeTempDir("codex-plugin-auth-broker-"); + const endpoint = createBrokerEndpoint(sessionDir); + const statePath = path.join(sessionDir, "state.json"); + const child = spawn( + process.execPath, + [FAKE_AUTH_BROKER, "--endpoint", endpoint, "--state", statePath, "--mode", mode], + { stdio: "ignore", windowsHide: true } + ); + + t.after(() => { + if (child.exitCode === null) { + child.kill("SIGTERM"); + } + }); + + await waitFor(() => { + if (!fs.existsSync(statePath)) { + return false; + } + try { + return JSON.parse(fs.readFileSync(statePath, "utf8")).ready; + } catch { + return false; + } + }); + + return { endpoint, statePath }; +} + +test("setup falls back directly when the shared broker is busy", async (t) => { + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const broker = await startFakeAuthBroker(t); + + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_APP_SERVER_ENDPOINT: broker.endpoint + } + }); + + 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.detail, "ChatGPT login active for test@example.com"); + assert.equal(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); +}); + +test("setup preserves a direct logged-out response after a busy broker fallback", async (t) => { + const binDir = makeTempDir(); + installFakeCodex(binDir, "logged-out"); + const broker = await startFakeAuthBroker(t); + + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_APP_SERVER_ENDPOINT: broker.endpoint + } + }); + + 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.detail, "OpenAI requires OpenAI authentication"); +}); + +test("setup falls back directly when the requested broker endpoint is stale", () => { + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + const sessionDir = makeTempDir("codex-plugin-stale-auth-broker-"); + installFakeCodex(binDir); + + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_APP_SERVER_ENDPOINT: createBrokerEndpoint(sessionDir) + } + }); + + 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(JSON.parse(fs.readFileSync(fakeStatePath, "utf8")).appServerStarts, 1); +}); + +test("setup does not retry a non-retryable broker auth failure", async (t) => { + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + const broker = await startFakeAuthBroker(t, "error"); + + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_APP_SERVER_ENDPOINT: broker.endpoint + } + }); + + 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.detail, "Broker auth request failed."); + assert.equal(fs.existsSync(fakeStatePath), false); +}); + +test("setup reports a direct auth failure and closes both fallback clients", async (t) => { + const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "config-read-fails"); + const broker = await startFakeAuthBroker(t); + + const result = run("node", [SCRIPT, "setup", "--json"], { + cwd: ROOT, + env: { + ...buildEnv(binDir), + CODEX_COMPANION_APP_SERVER_ENDPOINT: broker.endpoint + } + }); + + 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.detail, "config/read failed for cwd"); + + await waitFor(() => JSON.parse(fs.readFileSync(broker.statePath, "utf8")).closedConnections === 1); + const directState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(directState.appServerStarts, 1); + assert.equal(directState.appServerExits, 1); +}); + test("setup reports ready when fake codex is installed and authenticated", () => { const binDir = makeTempDir(); installFakeCodex(binDir);