Skip to content

Commit 03bb331

Browse files
fix(desktop): import Codex auth token
1 parent 6254f19 commit 03bb331

11 files changed

Lines changed: 246 additions & 34 deletions

File tree

apps/desktop/src/main/connection-ipc.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@open-codesign/shared';
99
import { ipcMain } from './electron-runtime';
1010
import { getApiKeyForProvider, getCachedConfig } from './onboarding-ipc';
11+
import { isKeylessProviderAllowed } from './provider-settings';
1112

1213
// ---------------------------------------------------------------------------
1314
// Payload schemas (plain validation, no zod in main to keep bundle lean)
@@ -358,8 +359,17 @@ function resolveCredentialsForProvider(
358359
let apiKey: string;
359360
try {
360361
apiKey = getApiKeyForProvider(providerId);
361-
} catch {
362+
} catch (err) {
362363
// No stored key — provider may be keyless (IP-whitelisted proxy).
364+
if (!isKeylessProviderAllowed(providerId, entry)) {
365+
return {
366+
ok: false,
367+
code: 'IPC_BAD_INPUT',
368+
message:
369+
err instanceof Error ? err.message : `No API key stored for provider "${providerId}"`,
370+
hint: 'Open Settings and import Codex again, or add an API key for this provider',
371+
};
372+
}
363373
apiKey = '';
364374
}
365375
return {
@@ -385,9 +395,7 @@ function resolveActiveCredentials(): ActiveProviderCredentials | ConnectionTestE
385395
return resolveCredentialsForProvider(active);
386396
}
387397

388-
async function runProviderTest(
389-
creds: ActiveProviderCredentials,
390-
): Promise<ConnectionTestResponse> {
398+
async function runProviderTest(creds: ActiveProviderCredentials): Promise<ConnectionTestResponse> {
391399
const { url } = buildEndpointForWire(creds.wire, creds.baseUrl);
392400
const headers = buildAuthHeadersForWire(creds.wire, creds.apiKey, creds.httpHeaders);
393401

@@ -594,8 +602,15 @@ export function registerConnectionIpc(): void {
594602
let apiKey: string;
595603
try {
596604
apiKey = getApiKeyForProvider(raw);
597-
} catch {
598-
// No stored key — provider may be keyless (IP-whitelisted proxy).
605+
} catch (err) {
606+
if (!isKeylessProviderAllowed(raw, entry)) {
607+
return {
608+
ok: false,
609+
code: 'IPC_BAD_INPUT',
610+
message: err instanceof Error ? err.message : `No API key stored for provider "${raw}"`,
611+
hint: 'Open Settings and import Codex again, or add an API key for this provider',
612+
};
613+
}
599614
apiKey = '';
600615
}
601616

apps/desktop/src/main/imports/codex-config.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
14
import { describe, expect, it } from 'vitest';
2-
import { parseCodexConfig } from './codex-config';
5+
import { parseCodexConfig, readCodexConfig } from './codex-config';
36

47
describe('parseCodexConfig', () => {
58
it('returns empty providers on empty TOML', async () => {
@@ -46,6 +49,17 @@ wire_api = "responses"
4649
expect(out.providers[0]?.defaultModel).toBe('gpt-4o');
4750
});
4851

52+
it('marks Codex providers that require OpenAI auth', async () => {
53+
const toml = `
54+
[model_providers.custom]
55+
base_url = "https://api.duckcoding.ai/v1"
56+
wire_api = "responses"
57+
requires_openai_auth = true
58+
`;
59+
const out = await parseCodexConfig(toml);
60+
expect(out.providers[0]?.requiresApiKey).toBe(true);
61+
});
62+
4963
it('uses provider-local model when a non-active provider declares one', async () => {
5064
const toml = `
5165
model = "deepseek-chat"
@@ -89,3 +103,31 @@ base_url = "https://proxy.anthropic.example.com"
89103
expect(out.providers[0]?.wire).toBe('anthropic');
90104
});
91105
});
106+
107+
describe('readCodexConfig', () => {
108+
it('reads OPENAI_API_KEY from Codex auth.json for providers requiring OpenAI auth', async () => {
109+
const home = join(tmpdir(), `open-codesign-codex-${Date.now()}-${Math.random()}`);
110+
const codexDir = join(home, '.codex');
111+
await mkdir(codexDir, { recursive: true });
112+
await writeFile(
113+
join(codexDir, 'config.toml'),
114+
`
115+
model_provider = "custom"
116+
model = "gpt-5.4"
117+
118+
[model_providers.custom]
119+
base_url = "https://api.duckcoding.ai/v1"
120+
wire_api = "responses"
121+
requires_openai_auth = true
122+
`,
123+
'utf8',
124+
);
125+
await writeFile(join(codexDir, 'auth.json'), '{"OPENAI_API_KEY":" sk-codex-auth "}', 'utf8');
126+
127+
const out = await readCodexConfig(home);
128+
129+
expect(out?.providers[0]?.id).toBe('codex-custom');
130+
expect(out?.providers[0]?.requiresApiKey).toBe(true);
131+
expect(out?.apiKeyMap['codex-custom']).toBe('sk-codex-auth');
132+
});
133+
});

apps/desktop/src/main/imports/codex-config.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ export function codexConfigPath(home: string = homedir()): string {
1010
return join(home, '.codex', 'config.toml');
1111
}
1212

13+
export function codexAuthPath(home: string = homedir()): string {
14+
return join(home, '.codex', 'auth.json');
15+
}
16+
1317
export interface CodexImport {
1418
providers: ProviderEntry[];
1519
activeProvider: string | null;
1620
activeModel: string | null;
1721
/** Env-key lookups the caller should run to resolve keys. */
1822
envKeyMap: Record<string, string>; // providerId → envVarName
23+
/** API keys resolved from Codex auth.json, keyed by imported provider id. */
24+
apiKeyMap: Record<string, string>;
1925
warnings: string[];
2026
}
2127

@@ -25,6 +31,7 @@ type CodexProviderBlock = {
2531
env_key?: string;
2632
model?: string;
2733
wire_api?: string;
34+
requires_openai_auth?: boolean;
2835
http_headers?: Record<string, string>;
2936
query_params?: Record<string, string>;
3037
};
@@ -49,6 +56,7 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
4956
activeProvider: null,
5057
activeModel: null,
5158
envKeyMap: {},
59+
apiKeyMap: {},
5260
warnings: [`Codex config.toml is not valid TOML: ${msg}`],
5361
};
5462
}
@@ -59,6 +67,7 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
5967
activeProvider: null,
6068
activeModel: null,
6169
envKeyMap: {},
70+
apiKeyMap: {},
6271
warnings: ['Codex config.toml has unexpected top-level shape'],
6372
};
6473
}
@@ -109,6 +118,9 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
109118
entry.envKey = block.env_key;
110119
envKeyMap[entry.id] = block.env_key;
111120
}
121+
if (block.requires_openai_auth === true) {
122+
entry.requiresApiKey = true;
123+
}
112124
if (block.http_headers !== undefined && typeof block.http_headers === 'object') {
113125
const map: Record<string, string> = {};
114126
for (const [k, v] of Object.entries(block.http_headers)) {
@@ -135,7 +147,34 @@ export async function parseCodexConfig(toml: string): Promise<CodexImport> {
135147
if (entry !== undefined) entry.defaultModel = activeModel;
136148
}
137149

138-
return { providers, activeProvider: activeProviderId, activeModel, envKeyMap, warnings };
150+
return {
151+
providers,
152+
activeProvider: activeProviderId,
153+
activeModel,
154+
envKeyMap,
155+
apiKeyMap: {},
156+
warnings,
157+
};
158+
}
159+
160+
async function readCodexOpenAiApiKey(home: string = homedir()): Promise<string | null> {
161+
let raw: string;
162+
try {
163+
raw = await readFile(codexAuthPath(home), 'utf8');
164+
} catch (err) {
165+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
166+
throw err;
167+
}
168+
let parsed: unknown;
169+
try {
170+
parsed = JSON.parse(raw);
171+
} catch {
172+
return null;
173+
}
174+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
175+
const record = parsed as Record<string, unknown>;
176+
const rawKey = record['OPENAI_API_KEY'] ?? record['openai_api_key'] ?? record['apiKey'];
177+
return typeof rawKey === 'string' && rawKey.trim().length > 0 ? rawKey.trim() : null;
139178
}
140179

141180
export async function readCodexConfig(home: string = homedir()): Promise<CodexImport | null> {
@@ -147,5 +186,15 @@ export async function readCodexConfig(home: string = homedir()): Promise<CodexIm
147186
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
148187
throw err;
149188
}
150-
return parseCodexConfig(raw);
189+
const imported = await parseCodexConfig(raw);
190+
const openAiApiKey = await readCodexOpenAiApiKey(home);
191+
if (openAiApiKey === null) return imported;
192+
193+
const apiKeyMap: Record<string, string> = {};
194+
for (const provider of imported.providers) {
195+
if (provider.requiresApiKey === true) {
196+
apiKeyMap[provider.id] = openAiApiKey;
197+
}
198+
}
199+
return { ...imported, apiKeyMap };
151200
}

apps/desktop/src/main/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
} from './onboarding-ipc';
4646
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
4747
import { preparePromptContext } from './prompt-context';
48-
import { isKeylessProviderAllowed, resolveActiveModel } from './provider-settings';
48+
import { resolveActiveModel } from './provider-settings';
4949
import { safeInitSnapshotsDb } from './snapshots-db';
5050
import { registerSnapshotsIpc, registerSnapshotsUnavailableIpc } from './snapshots-ipc';
5151
import { initStorageSettings } from './storage-settings';
@@ -447,7 +447,7 @@ function registerIpcHandlers(): void {
447447
} catch {
448448
apiKey = '';
449449
}
450-
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
450+
const allowKeyless = active.allowKeyless;
451451
// Once we've snapped to the canonical active provider, the renderer-supplied
452452
// baseUrl can no longer be trusted — it may belong to a different (stale)
453453
// provider and would route the active provider's API key to the wrong host.
@@ -587,7 +587,7 @@ function registerIpcHandlers(): void {
587587
} catch {
588588
apiKey = '';
589589
}
590-
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
590+
const allowKeyless = active.allowKeyless;
591591
// See codesign:v1:generate above — renderer baseUrl is ignored post-snap.
592592
const baseUrl = active.baseUrl ?? undefined;
593593
if (active.overridden) {
@@ -686,7 +686,7 @@ function registerIpcHandlers(): void {
686686
} catch {
687687
apiKey = '';
688688
}
689-
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
689+
const allowKeyless = active.allowKeyless;
690690
const baseUrl = active.baseUrl ?? undefined;
691691
const promptContext = await preparePromptContext({
692692
attachments: payload.attachments,
@@ -762,7 +762,7 @@ function registerIpcHandlers(): void {
762762
} catch {
763763
apiKey = '';
764764
}
765-
const allowKeyless = isKeylessProviderAllowed(active.model.provider);
765+
const allowKeyless = active.allowKeyless;
766766
const baseUrl = active.baseUrl ?? undefined;
767767
return generateTitle({
768768
prompt,

apps/desktop/src/main/onboarding-ipc.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ describe('config:v1:import-codex-config empty env handling', () => {
286286
activeProvider: 'codex-empty-env',
287287
activeModel: 'gpt-test',
288288
envKeyMap: { 'codex-empty-env': 'OPEN_CODESIGN_EMPTY_ENV_FOR_TEST' },
289+
apiKeyMap: {},
289290
warnings: [],
290291
});
291292
process.env['OPEN_CODESIGN_EMPTY_ENV_FOR_TEST'] = ' ';
@@ -294,12 +295,51 @@ describe('config:v1:import-codex-config empty env handling', () => {
294295
expect(handler).toBeDefined();
295296
await expect(handler?.({} as unknown)).resolves.toMatchObject({
296297
provider: 'codex-empty-env',
297-
hasKey: true,
298+
hasKey: false,
298299
});
299300

300301
expect(encryptSecret).not.toHaveBeenCalled();
301302
const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0];
302303
expect(written?.secrets['codex-empty-env']).toBeUndefined();
303304
process.env['OPEN_CODESIGN_EMPTY_ENV_FOR_TEST'] = undefined;
304305
});
306+
307+
it('encrypts Codex auth.json API keys for providers requiring OpenAI auth', async () => {
308+
const { readCodexConfig } = await import('./imports/codex-config');
309+
const { tryBuildSecretRef } = await import('./keychain');
310+
const { writeConfig } = await import('./config');
311+
vi.mocked(tryBuildSecretRef).mockClear();
312+
vi.mocked(writeConfig).mockClear();
313+
vi.mocked(readCodexConfig).mockResolvedValueOnce({
314+
providers: [
315+
{
316+
id: 'codex-custom',
317+
name: 'Codex (imported)',
318+
builtin: false,
319+
wire: 'openai-responses',
320+
baseUrl: 'https://api.duckcoding.ai/v1',
321+
defaultModel: 'gpt-5.4',
322+
requiresApiKey: true,
323+
},
324+
],
325+
activeProvider: 'codex-custom',
326+
activeModel: 'gpt-5.4',
327+
envKeyMap: {},
328+
apiKeyMap: { 'codex-custom': 'sk-codex-auth' },
329+
warnings: [],
330+
});
331+
332+
const handler = handlers.get('config:v1:import-codex-config');
333+
expect(handler).toBeDefined();
334+
await expect(handler?.({} as unknown)).resolves.toMatchObject({
335+
provider: 'codex-custom',
336+
hasKey: true,
337+
});
338+
339+
expect(tryBuildSecretRef).toHaveBeenCalledWith('sk-codex-auth');
340+
const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0];
341+
expect(written?.secrets['codex-custom']).toEqual(
342+
expect.objectContaining({ ciphertext: 'enc:sk-codex-auth' }),
343+
);
344+
});
305345
});

apps/desktop/src/main/onboarding-ipc.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function toState(cfg: Config | null): OnboardingState {
106106
}
107107
const active = cfg.activeProvider;
108108
const ref = cfg.secrets[active];
109-
if (ref === undefined && !isKeylessProviderAllowed(active)) {
109+
if (ref === undefined && !isKeylessProviderAllowed(active, cfg.providers[active])) {
110110
return {
111111
hasKey: false,
112112
provider: active,
@@ -696,13 +696,25 @@ async function runImportCodex(imported: CodexImport): Promise<OnboardingState> {
696696
}
697697
for (const entry of imported.providers) {
698698
nextProviders[entry.id] = entry;
699+
const importedApiKey = imported.apiKeyMap[entry.id]?.trim();
699700
if (entry.envKey !== undefined) {
700701
const envValue = process.env[entry.envKey]?.trim();
701702
if (envValue !== undefined && envValue.length > 0) {
702703
const ref = tryBuildSecretRef(envValue);
703704
if (ref !== null) nextSecrets[entry.id] = ref;
705+
continue;
704706
}
705707
}
708+
const fallbackApiKey =
709+
importedApiKey !== undefined && importedApiKey.length > 0
710+
? importedApiKey
711+
: entry.requiresApiKey === true
712+
? process.env['OPENAI_API_KEY']?.trim()
713+
: undefined;
714+
if (fallbackApiKey !== undefined && fallbackApiKey.length > 0) {
715+
const ref = tryBuildSecretRef(fallbackApiKey);
716+
if (ref !== null) nextSecrets[entry.id] = ref;
717+
}
706718
}
707719
const fallbackActive = imported.providers[0];
708720
if (fallbackActive === undefined) {

0 commit comments

Comments
 (0)