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:
parent
f17e7f8486
commit
62c351a7bf
@ -11,8 +11,8 @@ import {
|
|||||||
buildPersistentTaskThreadName,
|
buildPersistentTaskThreadName,
|
||||||
DEFAULT_CONTINUE_PROMPT,
|
DEFAULT_CONTINUE_PROMPT,
|
||||||
findLatestTaskThread,
|
findLatestTaskThread,
|
||||||
|
getCodexAuthStatus,
|
||||||
getCodexAvailability,
|
getCodexAvailability,
|
||||||
getCodexLoginStatus,
|
|
||||||
getSessionRuntimeStatus,
|
getSessionRuntimeStatus,
|
||||||
interruptAppServerTurn,
|
interruptAppServerTurn,
|
||||||
parseStructuredOutput,
|
parseStructuredOutput,
|
||||||
@ -176,19 +176,19 @@ function firstMeaningfulLine(text, fallback) {
|
|||||||
return line ?? fallback;
|
return line ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSetupReport(cwd, actionsTaken = []) {
|
async function buildSetupReport(cwd, actionsTaken = []) {
|
||||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||||
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
|
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
|
||||||
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
|
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
|
||||||
const codexStatus = getCodexAvailability(cwd);
|
const codexStatus = getCodexAvailability(cwd);
|
||||||
const authStatus = getCodexLoginStatus(cwd);
|
const authStatus = await getCodexAuthStatus(cwd);
|
||||||
const config = getConfig(workspaceRoot);
|
const config = getConfig(workspaceRoot);
|
||||||
|
|
||||||
const nextSteps = [];
|
const nextSteps = [];
|
||||||
if (!codexStatus.available) {
|
if (!codexStatus.available) {
|
||||||
nextSteps.push("Install Codex with `npm install -g @openai/codex`.");
|
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("Run `!codex login`.");
|
||||||
nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`.");
|
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, {
|
const { options } = parseCommandInput(argv, {
|
||||||
valueOptions: ["cwd"],
|
valueOptions: ["cwd"],
|
||||||
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
|
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}.`);
|
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);
|
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,14 +245,11 @@ function buildAdversarialReviewPrompt(context, focusText) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCodexReady(cwd) {
|
function ensureCodexAvailable(cwd) {
|
||||||
const authStatus = getCodexLoginStatus(cwd);
|
const availability = getCodexAvailability(cwd);
|
||||||
if (!authStatus.available) {
|
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`.");
|
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) {
|
function buildNativeReviewTarget(target) {
|
||||||
@ -325,7 +322,7 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeReviewRun(request) {
|
async function executeReviewRun(request) {
|
||||||
ensureCodexReady(request.cwd);
|
ensureCodexAvailable(request.cwd);
|
||||||
ensureGitRepository(request.cwd);
|
ensureGitRepository(request.cwd);
|
||||||
|
|
||||||
const target = resolveReviewTarget(request.cwd, {
|
const target = resolveReviewTarget(request.cwd, {
|
||||||
@ -429,7 +426,7 @@ async function executeReviewRun(request) {
|
|||||||
|
|
||||||
async function executeTaskRun(request) {
|
async function executeTaskRun(request) {
|
||||||
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
|
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
|
||||||
ensureCodexReady(request.cwd);
|
ensureCodexAvailable(request.cwd);
|
||||||
|
|
||||||
const taskMetadata = buildTaskRunMetadata({
|
const taskMetadata = buildTaskRunMetadata({
|
||||||
prompt: request.prompt,
|
prompt: request.prompt,
|
||||||
@ -728,7 +725,7 @@ async function handleTask(argv) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (options.background) {
|
if (options.background) {
|
||||||
ensureCodexReady(cwd);
|
ensureCodexAvailable(cwd);
|
||||||
requireTaskRequest(prompt, resumeLast);
|
requireTaskRequest(prompt, resumeLast);
|
||||||
|
|
||||||
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
|
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
|
||||||
@ -967,7 +964,7 @@ async function main() {
|
|||||||
|
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
case "setup":
|
case "setup":
|
||||||
handleSetup(argv);
|
await handleSetup(argv);
|
||||||
break;
|
break;
|
||||||
case "review":
|
case "review":
|
||||||
await handleReview(argv);
|
await handleReview(argv);
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
|
|||||||
capabilities?: InitializeCapabilities;
|
capabilities?: InitializeCapabilities;
|
||||||
brokerEndpoint?: string;
|
brokerEndpoint?: string;
|
||||||
disableBroker?: boolean;
|
disableBroker?: boolean;
|
||||||
|
reuseExistingBroker?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppServerMethodMap {
|
export interface AppServerMethodMap {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import process from "node:process";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
||||||
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
|
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||||
import { terminateProcessTree } from "./process.mjs";
|
import { terminateProcessTree } from "./process.mjs";
|
||||||
|
|
||||||
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
|
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
|
||||||
@ -333,7 +333,10 @@ export class CodexAppServerClient {
|
|||||||
let brokerEndpoint = null;
|
let brokerEndpoint = null;
|
||||||
if (!options.disableBroker) {
|
if (!options.disableBroker) {
|
||||||
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
|
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 });
|
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
|
||||||
brokerEndpoint = brokerSession?.endpoint ?? null;
|
brokerEndpoint = brokerSession?.endpoint ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
import { readJsonFile } from "./fs.mjs";
|
import { readJsonFile } from "./fs.mjs";
|
||||||
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
|
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
|
||||||
import { loadBrokerSession } from "./broker-lifecycle.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 SERVICE_NAME = "claude_code_codex_plugin";
|
||||||
const TASK_THREAD_PREFIX = "Codex Companion Task";
|
const TASK_THREAD_PREFIX = "Codex Companion Task";
|
||||||
@ -652,6 +652,134 @@ function buildResultStatus(turnState) {
|
|||||||
return turnState.finalTurn?.status === "completed" ? 0 : 1;
|
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) {
|
export function getCodexAvailability(cwd) {
|
||||||
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
|
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
|
||||||
if (!versionStatus.available) {
|
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);
|
const availability = getCodexAvailability(cwd);
|
||||||
if (!availability.available) {
|
if (!availability.available) {
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
loggedIn: 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 });
|
let client = null;
|
||||||
if (result.error) {
|
try {
|
||||||
return {
|
client = await CodexAppServerClient.connect(cwd, {
|
||||||
available: true,
|
env: options.env,
|
||||||
|
reuseExistingBroker: true
|
||||||
|
});
|
||||||
|
return await getCodexAuthStatusFromClient(client, cwd);
|
||||||
|
} catch (error) {
|
||||||
|
return buildAuthStatus({
|
||||||
loggedIn: false,
|
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 }) {
|
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;
|
let client = null;
|
||||||
try {
|
try {
|
||||||
client = brokerEndpoint
|
client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
|
||||||
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
|
|
||||||
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
|
||||||
await client.request("turn/interrupt", { threadId, turnId });
|
await client.request("turn/interrupt", { threadId, turnId });
|
||||||
return {
|
return {
|
||||||
attempted: true,
|
attempted: true,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import path from "node:path";
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
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 { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
|
||||||
import { getConfig, listJobs } from "./lib/state.mjs";
|
import { getConfig, listJobs } from "./lib/state.mjs";
|
||||||
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
|
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
|
||||||
@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSetupNote(cwd) {
|
function buildSetupNote(cwd) {
|
||||||
const authStatus = getCodexLoginStatus(cwd);
|
const availability = getCodexAvailability(cwd);
|
||||||
if (authStatus.available && authStatus.loggedIn) {
|
if (availability.available) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
|
const detail = availability.detail ? ` ${availability.detail}.` : "";
|
||||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
|
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStopReviewOutput(rawOutput) {
|
function parseStopReviewOutput(rawOutput) {
|
||||||
@ -175,4 +175,10 @@ function main() {
|
|||||||
logNote(runningTaskNote);
|
logNote(runningTaskNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|||||||
@ -63,6 +63,54 @@ function buildTurn(id, status = "inProgress", error = null) {
|
|||||||
return { id, status, items: [], error };
|
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) {
|
function send(message) {
|
||||||
process.stdout.write(JSON.stringify(message) + "\\n");
|
process.stdout.write(JSON.stringify(message) + "\\n");
|
||||||
}
|
}
|
||||||
@ -193,7 +241,7 @@ if (args[0] === "app-server" && args[1] === "--help") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (args[0] === "login" && args[1] === "status") {
|
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");
|
console.error("not authenticated");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@ -230,7 +278,21 @@ rl.on("line", (line) => {
|
|||||||
case "initialized":
|
case "initialized":
|
||||||
break;
|
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": {
|
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)) {
|
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
|
||||||
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
|
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,6 +65,77 @@ test("setup is ready without npm when Codex is already installed and authenticat
|
|||||||
assert.equal(payload.auth.loggedIn, true);
|
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", () => {
|
test("review renders a no-findings result from app-server review/start", () => {
|
||||||
const repo = makeTempDir();
|
const repo = makeTempDir();
|
||||||
const binDir = 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/);
|
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", () => {
|
test("review accepts the quoted raw argument style for built-in base-branch review", () => {
|
||||||
const repo = makeTempDir();
|
const repo = makeTempDir();
|
||||||
const binDir = 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);
|
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 repo = makeTempDir();
|
||||||
const binDir = makeTempDir();
|
const binDir = makeTempDir();
|
||||||
installFakeCodex(binDir, "logged-out");
|
installFakeCodex(binDir, "refreshable-auth");
|
||||||
initGitRepo(repo);
|
initGitRepo(repo);
|
||||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||||
run("git", ["add", "README.md"], { cwd: repo });
|
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.status, 0, allowed.stderr);
|
||||||
assert.equal(allowed.stdout.trim(), "");
|
assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i);
|
||||||
assert.match(allowed.stderr, /Codex is not set up for the review gate/i);
|
const payload = JSON.parse(allowed.stdout);
|
||||||
assert.match(allowed.stderr, /not authenticated/i);
|
assert.equal(payload.decision, "block");
|
||||||
assert.match(allowed.stderr, /!codex login/i);
|
assert.match(payload.reason, /Missing empty-state guard/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("commands lazily start and reuse one shared app-server after first use", async () => {
|
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);
|
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", () => {
|
test("status reports shared session runtime when a lazy broker is active", () => {
|
||||||
const repo = makeTempDir();
|
const repo = makeTempDir();
|
||||||
const binDir = makeTempDir();
|
const binDir = makeTempDir();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user