Compare commits

...
13 changed files with 625 additions and 82 deletions

No files matched your search

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { useCodexConfigModel } from './useCodexConfigModel';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const agent: ExternalAgentConfig = {
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
env: { CODEX_HOME: '/fixture/custom' },
};
for (const codexRuntime of ['sdk', 'app-server'] as const) {
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
let resolve!: (result: unknown) => void;
const calls: unknown[] = [];
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
} } });
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness({ config }: { config: ExternalAgentConfig }) {
result = useCodexConfigModel(config, true);
return null;
}
let root!: TestRenderer.ReactTestRenderer;
try {
const config = { ...agent, codexRuntime };
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
assert.equal(result.model, null);
let sent = false;
const sending = result.loadModel().then(model => { sent = true; return model; });
await Promise.resolve();
assert.equal(sent, false);
assert.equal(calls.length, 1);
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
assert.equal(await sending, 'glm-5');
assert.equal(result.model, 'glm-5');
// A different agent must not inherit the previous home/model or promise.
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
assert.equal(result.model, null);
assert.equal(calls.length, 2);
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
assert.equal(result.model, null);
} finally {
await act(async () => root?.unmount());
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
}
test('non-Codex agents keep their model flow without probing Codex', async () => {
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
let root!: TestRenderer.ReactTestRenderer;
try {
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
assert.equal(await result.loadModel(), null);
} finally { await act(async () => root.unmount()); }
});
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useMemo, useState } from 'react';
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
/** Share the config probe between the picker and sends, scoped to this agent. */
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
const loadModel = useMemo(() => {
let pending: Promise<string | null> | undefined;
return () => {
pending ??= Promise.resolve().then(async () => {
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
codexPath: getManualAgentCommand(agent),
agentEnv: agent.env,
});
return info?.customConfig?.model || null;
}).catch(() => null);
return pending;
};
// Reopening the panel refreshes config changed outside Netcatty.
}, [agent, isVisible]);
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
void loadModel().then(model => {
if (!cancelled) setResult({ loadModel, model });
});
return () => { cancelled = true; };
}, [isVisible, loadModel]);
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
}
+13 -42
View File
@@ -48,7 +48,7 @@ import {
isInlineTextAttachment,
} from '../application/state/terminalSelectionAttachment';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -843,35 +843,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => {
cancelled = true;
};
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
currentAgentConfig, isVisible,
);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
@@ -1035,18 +1009,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
return [];
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) return runtimePresets;
const presets = getAgentModelPresets(
@@ -1236,8 +1204,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (sendBridge?.aiSyncWebSearch) {
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
let sendSelectedAgentModel = selectedAgentModel;
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
// A quick send must await the same config probe as the picker. Its model
// also takes precedence over App Server's built-in model catalog.
const configModel = await loadCodexConfigModel();
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
@@ -1421,7 +1392,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions,
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
+11 -10
View File
@@ -645,6 +645,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, 900);
}, []);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
@@ -655,7 +660,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration(opts);
const integration = await bridge.aiCodexGetIntegration({ ...opts, agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
setCodexIntegration(integration);
} catch (err) {
@@ -667,7 +672,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, []);
}, [codexManagedAgent?.env]);
const codexCommittedPath = useMemo(
() => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined,
@@ -682,10 +687,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codexCommittedPath
), [codexCommittedPath]);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk';
const refreshCodexAppServerStatus = useCallback(async () => {
@@ -861,7 +862,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
@@ -876,7 +877,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride]);
}, [getCodexPathOverride, codexManagedAgent?.env]);
const handleCancelCodexLogin = useCallback(async () => {
const bridge = getBridge();
@@ -912,7 +913,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
@@ -928,7 +929,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride, refreshCodexIntegration]);
}, [getCodexPathOverride, refreshCodexIntegration, codexManagedAgent?.env]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
+3 -3
View File
@@ -152,11 +152,11 @@ export interface FetchBridge {
export interface NetcattyAiBridge {
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
+13 -1
View File
@@ -262,9 +262,21 @@ function parseCodexConfigToml(text) {
* because the config.toml is a strong signal the user doesn't want that.
*/
function readCodexCustomProviderConfig(shellEnv) {
// Codex honors $CODEX_HOME and reads $CODEX_HOME/config.toml (see
// codexAppServer/protocol.schema.json), so prefer it over the legacy
// ~/.codex location — otherwise a custom provider configured under
// CODEX_HOME would be missed (or an unrelated default-home config could
// shadow what the CLI actually runs).
const codexHome = typeof shellEnv?.CODEX_HOME === "string" ? shellEnv.CODEX_HOME.trim() : "";
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
if (codexHome) {
return readCodexCustomProviderConfigFrom(path.join(codexHome, "config.toml"), shellEnv);
}
if (!home) return null;
const configPath = path.join(home, ".codex", "config.toml");
return readCodexCustomProviderConfigFrom(path.join(home, ".codex", "config.toml"), shellEnv);
}
function readCodexCustomProviderConfigFrom(configPath, shellEnv) {
if (!existsSync(configPath)) return null;
let text;
+98
View File
@@ -1,5 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
MAX_CODEX_LOGIN_OUTPUT_CHARS,
@@ -13,6 +16,7 @@ const {
extractCodexError,
isCodexAuthError,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
recordCodexLoginSession,
stopCodexLoginProcess,
} = require("./codexHelpers.cjs");
@@ -177,3 +181,97 @@ test("extractCodexError handles circular structured errors", () => {
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
});
test("readCodexCustomProviderConfig parses a switcher-style third-party config.toml", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
[
'model_provider = "ccs"',
'model = "glm-5"',
"",
"[model_providers.ccs]",
'name = "Coding Plan"',
'base_url = "https://example.invalid/v1"',
'wire_api = "responses"',
'env_key = "CODING_PLAN_API_KEY"',
"",
].join("\n"),
"utf8",
);
const config = readCodexCustomProviderConfig({
HOME: tempDir,
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
});
assert.equal(config?.providerName, "ccs");
assert.equal(config?.displayName, "Coding Plan");
assert.equal(config?.baseUrl, "https://example.invalid/v1");
assert.equal(config?.model, "glm-5");
assert.equal(config?.envKey, "CODING_PLAN_API_KEY");
assert.equal(config?.envKeyPresent, true);
});
test("readCodexCustomProviderConfig returns null for the default openai provider", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
'model_provider = "openai"\n',
"utf8",
);
assert.equal(readCodexCustomProviderConfig({ HOME: tempDir }), null);
});
test("readCodexCustomProviderConfig honors CODEX_HOME over ~/.codex", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-home-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
// An unrelated config in the default home must be ignored when CODEX_HOME
// points elsewhere.
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
'model_provider = "stale"\n\n[model_providers.stale]\nname = "Stale"\n',
"utf8",
);
const codexHome = path.join(tempDir, "custom-home");
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
path.join(codexHome, "config.toml"),
[
'model_provider = "ccs"',
'model = "glm-5"',
"",
"[model_providers.ccs]",
'name = "Coding Plan"',
'base_url = "https://example.invalid/v1"',
'env_key = "CODING_PLAN_API_KEY"',
"",
].join("\n"),
"utf8",
);
const config = readCodexCustomProviderConfig({
HOME: tempDir,
CODEX_HOME: codexHome,
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
});
assert.equal(config?.providerName, "ccs");
assert.equal(config?.model, "glm-5");
});
+2
View File
@@ -29,6 +29,7 @@ const { registerProviderHandlers } = require("./aiBridge/providerHandlers.cjs"),
const { createVaultAgentBridge } = require("./aiBridge/vaultAgentBridge.cjs");
const { registerAgentDiscoveryHandlers } = require("./aiBridge/agentDiscoveryHandlers.cjs"), { registerAgentProcessHandlers } = require("./aiBridge/agentProcessHandlers.cjs"), { registerSdkStreamHandlers } = require("./aiBridge/sdk/sdkStreamHandlers.cjs");
const { probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth } = require("./aiBridge/agentAuthProbes.cjs");
const { buildSdkAgentEnv } = require("./aiBridge/sdk/env.cjs");
// ── Extracted modules ──
const {
@@ -946,6 +947,7 @@ function createHandlerContext(ipcMain) {
killTrackedProcessTree,
safeSend,
withCliDiscoveryEnv,
buildSdkAgentEnv,
validateSender,
validateSenderOrSettings,
envPairsToObject,
+186 -1
View File
@@ -109,6 +109,9 @@ function loadBridgeWithMocks(options = {}) {
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
appendCodexLoginOutput() {},
createCodexLoginOutputDecoder: () => ({ write() {}, end() {} }),
clearCodexLoginKillTimer() {},
recordCodexLoginSession: (session) => options.recordCodexLoginSession?.(session),
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
getActiveCodexLoginSession: () =>
typeof options.getActiveCodexLoginSession === "function"
@@ -120,7 +123,10 @@ function loadBridgeWithMocks(options = {}) {
: realNormalizeCodexIntegrationState(...args),
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
readCodexCustomProviderConfig: () => null,
readCodexCustomProviderConfig: (...args) =>
typeof options.readCodexCustomProviderConfig === "function"
? options.readCodexCustomProviderConfig(...args)
: null,
getCodexCustomConfigPreflightError: () => null,
extractCodexError: (err) => ({ message: err?.message || String(err) }),
isCodexAuthError: (...args) =>
@@ -971,6 +977,84 @@ test("codex login does not reuse an active session from a different resolved pat
}
});
for (const homeVariable of ["HOME", "CODEX_HOME"]) {
for (const sameHome of [true, false]) {
test(`codex login ${sameHome ? "reuses" : "rejects"} an active session with ${sameHome ? "matching" : "changed"} ${homeVariable}`, async () => {
const originalHome = path.join(os.tmpdir(), "codex-login-original");
const requestedHome = sameHome ? originalHome : path.join(os.tmpdir(), "codex-login-other");
const existingSession = {
id: "codex_login_existing",
state: "running",
process: { killed: false },
codexPath: "/fixture/codex",
credentialHomeKey: homeVariable === "HOME" ? path.join(originalHome, ".codex") : originalHome,
};
const { bridge, restore } = loadBridgeWithMocks({
shellEnv: { HOME: originalHome },
resolveCliFromPathAsync: () => existingSession.codexPath,
getActiveCodexLoginSession: () => existingSession,
prepareCommandForSpawn: () => { throw new Error("An active login must not spawn another process"); },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const result = await ipcMain.handlers.get("netcatty:ai:codex:start-login")(
{ sender: { id: 1 } },
{ agentEnv: { [homeVariable]: requestedHome } },
);
assert.equal(result.ok, sameHome, JSON.stringify(result));
if (sameHome) {
assert.equal(result.session.sessionId, existingSession.id);
} else {
assert.match(result.error, /different credential home/);
assert.equal(result.session, undefined);
}
} finally {
restore();
}
});
}
}
for (const action of ["start-login", "logout"]) {
test(`codex ${action} child processes use the requested agent home`, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-agent-home-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const codexPath = path.join(tempDir, "codex.cjs");
const observedPath = path.join(tempDir, "observed.jsonl");
fs.writeFileSync(codexPath, `
require('node:fs').appendFileSync(${JSON.stringify(observedPath)}, JSON.stringify({
args: process.argv.slice(2), HOME: process.env.HOME, CODEX_HOME: process.env.CODEX_HOME,
}) + '\\n');
console.log('Not logged in');
`);
const agentEnv = { HOME: path.join(tempDir, "agent-home"), CODEX_HOME: path.join(tempDir, "agent-codex") };
let finishLogin;
const loginFinished = new Promise((resolve) => { finishLogin = resolve; });
const { bridge, restore } = loadBridgeWithMocks({
shellEnv: { HOME: path.join(tempDir, "default-home"), CODEX_HOME: path.join(tempDir, "default-codex") },
prepareCommandForSpawn: (command, args) => ({ command: process.execPath, args: [command, ...args], shell: false }),
recordCodexLoginSession: (session) => {
if (session.state !== "running") finishLogin(session.state);
},
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const result = await ipcMain.handlers.get(`netcatty:ai:codex:${action}`)({ sender: { id: 1 } }, { codexPath, agentEnv });
assert.equal(result.ok, true, JSON.stringify(result));
if (action === "start-login") assert.equal(await loginFinished, "success");
const observed = fs.readFileSync(observedPath, "utf8").trim().split("\n").map((line) => JSON.parse(line));
assert.deepEqual(observed, (action === "start-login" ? [["login"]] : [["logout"], ["login", "status"]])
.map((args) => ({ args, ...agentEnv })));
} finally {
restore();
}
});
}
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
@@ -1014,6 +1098,107 @@ test("codex integration keeps ChatGPT connected when the SDK validation probe fa
}
});
test("codex integration surfaces config.toml provider even when auth.json reports an API-key login", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex");
fs.writeFileSync(
codexPath,
`#!${process.execPath}\nconsole.log('Logged in using an API key');\n`,
{ mode: 0o755 },
);
const customConfig = {
providerName: "ccs",
displayName: "Coding Plan",
baseUrl: "https://example.invalid/v1",
envKey: null,
envKeyPresent: false,
hasHardcodedApiKey: true,
model: "glm-5",
authHash: "hash",
};
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: (value) => value,
shellEnv: { HOME: tempDir },
readCodexCustomProviderConfig: () => customConfig,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
const result = await handler({ sender: { id: 1 } }, { codexPath });
assert.equal(result.state, "connected_custom_config", JSON.stringify(result));
assert.equal(result.isConnected, true);
assert.equal(result.customConfig?.model, "glm-5");
assert.equal(result.customConfig?.providerName, "ccs");
} finally {
restore();
}
});
test("codex integration keeps a validated ChatGPT login visible but still returns config.toml provider", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex");
fs.writeFileSync(
codexPath,
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
{ mode: 0o755 },
);
const customConfig = {
providerName: "ccs",
displayName: "Coding Plan",
baseUrl: null,
envKey: null,
envKeyPresent: false,
hasHardcodedApiKey: true,
model: "glm-5",
authHash: "hash",
};
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: (value) => value,
shellEnv: { HOME: tempDir },
readCodexCustomProviderConfig: () => customConfig,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
const result = await handler({ sender: { id: 1 } }, { codexPath });
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
assert.equal(result.isConnected, true);
assert.equal(result.customConfig?.model, "glm-5");
} finally {
restore();
}
});
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
t.after(() => {
+25 -8
View File
@@ -1,4 +1,5 @@
/* eslint-disable no-undef */
const { createHash } = require("node:crypto");
const { StringDecoder } = require("node:string_decoder");
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
@@ -8,6 +9,7 @@ const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
function createAgentCliHelpers(ctx) {
with (ctx) {
const codexAuthValidationInFlight = new Map();
let codexValidationKey = null;
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
let settled = false;
@@ -143,9 +145,15 @@ function createAgentCliHelpers(ctx) {
throw new Error(`Codex CLI path not found: ${requestedPath}`);
}
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
// Callers probing on behalf of a specific agent pass `options.env` (the
// merged shell + agent env, as built for SDK spawns) so CODEX_HOME/HOME
// overrides select the same config the agent turn will use.
const env = options?.env && typeof options.env === "object" && Object.keys(options.env).length > 0
? options.env
: shellEnv;
return await runCommand(codexCliPath, args, {
cwd: options?.cwd?.trim() || undefined,
env: shellEnv,
env,
timeoutMs: Number.isFinite(options?.timeoutMs)
? Number(options.timeoutMs)
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
@@ -166,10 +174,21 @@ function createAgentCliHelpers(ctx) {
}
async function validateCodexChatGptAuth(options) {
const shellEnv = options?.env || await getShellEnv();
// Hash sorted entries so equivalent env objects share probes without
// retaining raw credentials in cache keys.
const envKey = createHash("sha256")
.update(JSON.stringify(Object.entries(shellEnv).sort(([a], [b]) => a.localeCompare(b))))
.digest("hex");
const maxAgeMs = options?.maxAgeMs ?? 30000;
const now = Date.now();
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
const validationKey = JSON.stringify([requestedCodexPath, envKey]);
const cacheValidation = (result) => {
codexValidationKey = validationKey;
setCodexValidationCache(result);
};
if (rawRequestedCodexPath && !requestedCodexPath) {
const result = {
ok: false,
@@ -178,24 +197,22 @@ function createAgentCliHelpers(ctx) {
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
code: "ENOENT",
};
setCodexValidationCache(result);
return result;
}
const cached = getCodexValidationCache();
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
const inFlightKey = requestedCodexPath || "__auto__";
if (codexValidationKey === validationKey && cached && now - cached.checkedAt < maxAgeMs) return cached;
const inFlightKey = validationKey;
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
if (existingValidation) return existingValidation;
const validationPromise = (async () => {
const shellEnv = await getShellEnv();
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
? resolveCodexExecutableForSdk(rawCodexPath) || null
: rawCodexPath;
if (!codexPath) {
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
setCodexValidationCache(result);
cacheValidation(result);
return result;
}
@@ -244,12 +261,12 @@ function createAgentCliHelpers(ctx) {
await Promise.race([probePromise, timeoutPromise]);
const result = { ok: true, checkedAt: now, codexPath, error: null };
setCodexValidationCache(result);
cacheValidation(result);
return result;
} catch (error) {
const normalized = extractCodexError(error);
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
setCodexValidationCache(result);
cacheValidation(result);
return result;
} finally {
if (timeoutId) clearTimeout(timeoutId);
@@ -125,7 +125,7 @@ test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async ()
assert.doesNotMatch(result.stdout, /�/u);
});
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout, ...overrides }) {
return createAgentCliHelpers({
getCodexValidationCache: () => null,
setCodexValidationCache() {},
@@ -138,6 +138,147 @@ function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
loadCodexSdk,
...(setTimeout ? { setTimeout } : {}),
...(clearTimeout ? { clearTimeout } : {}),
...overrides,
});
}
test("agent auth validation does not reuse results from a different environment", async () => {
const defaultEnv = { HOME: "/default-home" };
const agentEnv = { HOME: "/agent-home", CODEX_HOME: "/agent-codex", CUSTOM_KEY: "test-value" };
const defaultFailure = { ok: false, checkedAt: Date.now(), codexPath: "/fake/codex", error: "default login expired" };
let cached = defaultFailure;
const receivedEnvs = [];
const helpers = createValidationHelpers({
getShellEnv: async () => defaultEnv,
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
constructor(options) { receivedEnvs.push(options.env); }
startThread() {
return { async runStreamed() {
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: agentEnv });
assert.equal(agentResult.ok, true);
assert.deepEqual(receivedEnvs, [agentEnv]);
assert.equal(cached, agentResult);
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
assert.equal(defaultResult.ok, true);
assert.deepEqual(receivedEnvs, [agentEnv, defaultEnv]);
assert.equal(cached, defaultResult);
});
test("agent auth failure cannot become the default environment's cached result", async () => {
let cached = null;
const receivedHomes = [];
const helpers = createValidationHelpers({
getShellEnv: async () => ({ HOME: "/default-home" }),
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
constructor(options) { this.home = options.env.HOME; receivedHomes.push(this.home); }
startThread() {
const home = this.home;
return { async runStreamed() {
if (home === "/agent-home") throw new Error("agent login expired");
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: { HOME: "/agent-home" } });
assert.equal(agentResult.ok, false);
assert.equal(cached, agentResult);
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
assert.equal(defaultResult.ok, true);
assert.deepEqual(receivedHomes, ["/agent-home", "/default-home"]);
assert.equal(cached, defaultResult);
});
test("an invalid explicit CLI path does not poison automatic discovery auth validation", async () => {
let cached = null;
let probeCount = 0;
const helpers = createValidationHelpers({
getShellEnv: async () => ({ HOME: "/agent-home" }),
normalizeCliPathForPlatform: () => null,
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
startThread() {
probeCount += 1;
return { async runStreamed() {
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const invalid = await helpers.validateCodexChatGptAuth({ codexPath: "/missing/codex" });
assert.equal(invalid.ok, false);
assert.equal(invalid.code, "ENOENT");
assert.equal(cached, null);
assert.equal(probeCount, 0);
const discovered = await helpers.validateCodexChatGptAuth({});
assert.equal(discovered.ok, true);
assert.equal(discovered.codexPath, "/fake/codex");
assert.equal(probeCount, 1);
assert.equal(await helpers.validateCodexChatGptAuth({}), discovered);
assert.equal(probeCount, 1);
});
for (const codexPath of ["/fake/codex", undefined]) {
test(`equivalent auth environments coalesce and cache with ${codexPath ? "explicit" : "discovered"} CLI path; credential rotation probes again`, async () => {
const defaultEnv = { HOME: "/agent-home", CUSTOM_KEY: "old-test-key" };
let cached = null;
let runCount = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const helpers = createValidationHelpers({
getShellEnv: async () => ({ ...defaultEnv }),
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
startThread() {
return { async runStreamed() {
runCount += 1;
return { events: (async function* () {
await gate;
yield { type: "turn.completed" };
})() };
} };
}
},
}),
});
const options = { codexPath };
const first = helpers.validateCodexChatGptAuth({ ...options, env: defaultEnv });
const second = helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } });
const third = helpers.validateCodexChatGptAuth(options);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runCount, 1);
release();
const results = await Promise.all([first, second, third]);
assert.equal(results[0].ok, true);
assert.equal(results[0], results[1]);
assert.equal(results[0], results[2]);
assert.equal(await helpers.validateCodexChatGptAuth(options), results[0]);
assert.equal(await helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } }), results[0]);
assert.equal(runCount, 1);
const rotated = await helpers.validateCodexChatGptAuth({ ...options, env: { ...defaultEnv, CUSTOM_KEY: "new-test-key" } });
assert.equal(rotated.ok, true);
assert.notEqual(rotated, results[0]);
assert.equal(runCount, 2);
});
}
@@ -78,6 +78,14 @@ async function probeCursorSdkAvailability(shellEnv, options = {}) {
function registerAgentDiscoveryHandlers(ctx) {
with (ctx) {
async function getCodexAgentEnv(options) {
return buildSdkAgentEnv({
shellEnv: await getShellEnv(),
requestedAgentEnv: normalizeAgentEnv(options?.agentEnv),
withCliDiscoveryEnv,
});
}
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (options?.refreshShellEnv) {
@@ -265,7 +273,9 @@ function registerAgentDiscoveryHandlers(ctx) {
invalidateShellEnvCache();
}
try {
const codexCliOptions = { codexPath: options?.codexPath };
// Probe the same config and credentials as the managed agent's turns.
const effectiveEnv = await getCodexAgentEnv(options);
const codexCliOptions = { codexPath: options?.codexPath, env: effectiveEnv };
const result = await runCodexCli(["login", "status"], codexCliOptions);
const rawOutput = [result.stdout, result.stderr]
.filter((chunk) => chunk.trim().length > 0)
@@ -275,7 +285,7 @@ function registerAgentDiscoveryHandlers(ctx) {
let effectiveRawOutput = rawOutput;
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath, env: effectiveEnv });
if (!validation.ok) {
if (isCodexAuthError(validation)) {
try {
@@ -299,17 +309,21 @@ function registerAgentDiscoveryHandlers(ctx) {
// functional from the CLI but would look "not_logged_in" here. Probe
// config.toml so we can surface that as a valid ready state instead of
// pushing the user into the ChatGPT login flow.
//
// Probe even when auth.json reports a login: provider switcher tools
// (cc-switch, ccs) write an API key into auth.json while config.toml's
// `model_provider` actually selects a third-party provider, and
// config.toml is what Codex uses. Keep a validated ChatGPT login as the
// displayed state, but still return customConfig so the chat model
// picker can surface the configured third-party model.
let customConfig = null;
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
try {
const shellEnv = await getShellEnv();
customConfig = readCodexCustomProviderConfig(shellEnv);
if (customConfig) {
state = "connected_custom_config";
}
} catch {
customConfig = null;
try {
customConfig = readCodexCustomProviderConfig(effectiveEnv);
if (customConfig && state !== "connected_chatgpt") {
state = "connected_custom_config";
}
} catch {
customConfig = null;
}
return {
@@ -342,16 +356,21 @@ function registerAgentDiscoveryHandlers(ctx) {
}
try {
const shellEnv = await getShellEnv();
const shellEnv = await getCodexAgentEnv(options);
const codexCliPath = requestedCodexPath
|| await resolveCliFromPathAsync("codex", shellEnv)
|| "codex";
const credentialHomeKey = shellEnv.CODEX_HOME?.trim()
|| require("node:path").join(shellEnv.HOME || shellEnv.USERPROFILE || require("node:os").homedir(), ".codex");
const existingSession = getActiveCodexLoginSession();
if (existingSession) {
const existingPath = existingSession.codexPath || null;
if (existingPath && codexCliPath !== existingPath) {
return { ok: false, error: "A Codex login is already running for a different CLI path." };
}
if (existingSession.credentialHomeKey !== credentialHomeKey) {
return { ok: false, error: "A Codex login is already running for a different credential home." };
}
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
}
@@ -373,6 +392,7 @@ function registerAgentDiscoveryHandlers(ctx) {
error: null,
exitCode: null,
codexPath: codexCliPath,
credentialHomeKey,
};
const stdoutDecoder = createCodexLoginOutputDecoder(session);
@@ -454,7 +474,7 @@ function registerAgentDiscoveryHandlers(ctx) {
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const codexCliOptions = { codexPath: options?.codexPath };
const codexCliOptions = { codexPath: options?.codexPath, env: await getCodexAgentEnv(options) };
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
invalidateCodexValidationCache();
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
+3 -3
View File
@@ -29,7 +29,7 @@ declare global {
acpArgs?: string[];
}>>;
aiPrewarmShellEnv?(): Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }): Promise<{
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;
@@ -45,7 +45,7 @@ declare global {
authHash: string | null;
} | null;
}>;
aiCodexStartLogin?(options?: { codexPath?: string }): Promise<{
aiCodexStartLogin?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
ok: boolean;
session?: {
sessionId: string;
@@ -85,7 +85,7 @@ declare global {
};
error?: string;
}>;
aiCodexLogout?(options?: { codexPath?: string }): Promise<{
aiCodexLogout?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
ok: boolean;
state?: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected?: boolean;