Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 44 additions & 22 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(() => {});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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, {
Expand All @@ -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(() => {});
Expand Down
96 changes: 96 additions & 0 deletions tests/fake-auth-broker.mjs
Original file line number Diff line number Diff line change
@@ -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 <endpoint> --state <path> --mode <busy|error>");
}

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();
});
7 changes: 6 additions & 1 deletion tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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;
Expand Down
143 changes: 143 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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();
Expand All @@ -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);
Expand Down