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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/desktop/src/bun/env/hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,3 @@ function _parseEnvBlock(
}
return env;
}

// Run on import so the environment is fixed before any other module reads
// `process.env`. `bun/index.ts` imports this first for that reason.
hydrateShellEnv();
38 changes: 22 additions & 16 deletions apps/desktop/src/bun/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
/* eslint-disable import-x/order -- load order is load-bearing: `./env/hydrate`
must resolve the real login-shell environment (API keys, PATH) before any
other module reads `process.env`. GUI launches don't inherit it. */
import "./env/hydrate";
// Attach the deep-link (`open-url`) listener at the earliest possible point so a
// cold-start launch URL isn't dropped before the composition root is ready.
import "./deep-link/launch";
// Seed a fresh workspace (before `./app` pulls in storage/RPC).
import "./workspace/seed";
// Seed the managed skills folder (before `./app` pulls in the SkillsManager).
import "./skills/seed";
/* eslint-enable import-x/order */
import { hydrateShellEnv } from "./env/hydrate";

// Dynamic import is intentional: environment hydration and seeding must finish
// before the composition root evaluates manager modules and reads configuration.
const { startDesktopApp } = await import("./app");
await startDesktopApp();
/**
* Initialize process state before loading the desktop composition root.
*/
async function _bootstrapDesktopApp(): Promise<void> {
hydrateShellEnv();

// Attach the listener immediately after hydration so cold-start deep links
// are buffered before the longer bootstrap imports evaluate.
await import("./deep-link/launch");

const { seedWorkspace } = await import("./workspace/seed");
seedWorkspace();

const { seedSkills } = await import("./skills/seed");
seedSkills();

const { startDesktopApp } = await import("./app");
await startDesktopApp();
}

await _bootstrapDesktopApp();
4 changes: 0 additions & 4 deletions apps/desktop/src/bun/skills/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,3 @@ export function seedSkills(): void {
writeFileSync(path.join(dir, "SKILL.md"), content, "utf8");
}
}

// Run on import so the managed skills folder exists before the SkillsManager
// (and the Skill tool) read it.
seedSkills();
3 changes: 0 additions & 3 deletions apps/desktop/src/bun/workspace/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,3 @@ export function seedWorkspace(): void {
}
mkdirSync(workspace, { recursive: true });
}

// Run on import so the workspace is seeded before storage/RPC touch it.
seedWorkspace();
16 changes: 2 additions & 14 deletions packages/runtime/src/models/model-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { env } from "node:process";

Expand All @@ -22,6 +21,7 @@ import {
BUILTIN_PROVIDERS,
} from "./providers/builtin-providers";
import { createCustomProvider } from "./providers/custom-provider";
import { getCodexCredentials } from "./providers/openai-codex";
import {
DEFAULT_CUSTOM_PROVIDER_API,
type CustomModelConfig,
Expand Down Expand Up @@ -666,18 +666,6 @@ export class ModelManager {
}

private _getCodexApiKey(): string | undefined {
const authPath = path.join(os.homedir(), ".codex", "auth.json");
if (!existsSync(authPath)) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as {
tokens?: { access_token?: unknown };
};
const accessToken = parsed?.tokens?.access_token;
return typeof accessToken === "string" ? accessToken : undefined;
} catch {
return undefined;
}
return getCodexCredentials()?.apiKey;
}
}
46 changes: 45 additions & 1 deletion packages/runtime/src/models/providers/openai-codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { openaiCodexProvider } from "./openai-codex";

describe("openaiCodexProvider", () => {
test("resolves the Codex CLI access token passed as an API-key override", async () => {
const provider = openaiCodexProvider();
const provider = openaiCodexProvider(null);
const model = provider.getModels()[0];
const models = createModels();
models.setProvider(provider);
Expand All @@ -20,4 +20,48 @@ describe("openaiCodexProvider", () => {

expect(auth?.auth.apiKey).toBe("codex-cli-access-token");
});

test("uses the configured endpoint and API for API key credentials", async () => {
const provider = openaiCodexProvider({
api: "anthropic-messages",
apiKey: "codex-cli-api-key",
baseUrl: "https://proxy.example.com",
mode: "apiKey",
});
const model = provider.getModels()[0];

expect(provider.auth.oauth).toBeUndefined();
expect(provider.baseUrl).toBe("https://proxy.example.com");
expect(model?.api).toBe("anthropic-messages");
expect(model?.baseUrl).toBe("https://proxy.example.com");

const auth = await provider.auth.apiKey?.resolve({
ctx: {
env: () => Promise.resolve(undefined),
fileExists: () => Promise.resolve(false),
},
});
expect(auth).toMatchObject({
auth: { apiKey: "codex-cli-api-key" },
source: "Codex CLI API key",
});
});

test("prefers a per-run API-key override over CLI credentials", async () => {
const provider = openaiCodexProvider({
api: "openai-responses",
apiKey: "codex-cli-api-key",
baseUrl: "https://proxy.example.com",
mode: "apiKey",
});
const model = provider.getModels()[0];
const models = createModels();
models.setProvider(provider);

const auth = await models.getAuth(model, {
apiKey: "per-run-api-key",
});

expect(auth?.auth.apiKey).toBe("per-run-api-key");
});
});
21 changes: 0 additions & 21 deletions packages/runtime/src/models/providers/openai-codex.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { CustomProviderApi } from "../../types";

export type CodexCredentials =
| {
api: CustomProviderApi;
apiKey: string;
baseUrl: string;
mode: "apiKey";
}
| {
apiKey: string;
mode: "oauth";
};
126 changes: 126 additions & 0 deletions packages/runtime/src/models/providers/openai-codex/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";

import type { CustomProviderApi } from "../../types";

import type { CodexCredentials } from "./codex-credentials";

const WIRE_API_MAP: Record<string, CustomProviderApi> = {
completions: "openai-completions",
messages: "anthropic-messages",
responses: "openai-responses",
};

/**
* Read Codex CLI credentials and the active API key provider configuration.
*
* @param codexDir The Codex CLI configuration directory.
*/
export function getCodexCredentials(
codexDir = path.join(os.homedir(), ".codex")
): CodexCredentials | undefined {
const auth = _readAuthJSON(path.join(codexDir, "auth.json"));
if (!auth) {
return undefined;
}

if (auth.oauthToken) {
return { apiKey: auth.oauthToken, mode: "oauth" };
}

if (!auth.apiKey) {
return undefined;
}

const config = _readConfigToml(path.join(codexDir, "config.toml"));
return {
api: config?.api ?? "openai-responses",
apiKey: auth.apiKey,
baseUrl: config?.baseUrl ?? "",
mode: "apiKey",
};
}

function _readAuthJSON(
authPath: string
): { apiKey?: string; oauthToken?: string } | undefined {
if (!existsSync(authPath)) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf8")) as Record<
string,
unknown
>;
const tokens = parsed.tokens as { access_token?: unknown } | undefined;
const oauthToken =
typeof tokens?.access_token === "string"
? tokens.access_token
: undefined;
const apiKey =
typeof parsed.OPENAI_API_KEY === "string"
? parsed.OPENAI_API_KEY
: undefined;

return oauthToken || apiKey ? { apiKey, oauthToken } : undefined;
} catch {
return undefined;
}
}

function _readConfigToml(
configPath: string
): { api: CustomProviderApi; baseUrl: string } | undefined {
if (!existsSync(configPath)) {
return undefined;
}
try {
const lines = readFileSync(configPath, "utf8").split("\n");
const activeProvider = lines
.map((line) => /^\s*model_provider\s*=\s*"([^"]+)"/.exec(line)?.[1])
.find((value) => value !== undefined);
if (!activeProvider) {
return undefined;
}

const sectionHeader = `[model_providers.${activeProvider}]`;
let inSection = false;
let baseUrl: string | undefined;
let wireApi: string | undefined;

for (const line of lines) {
const trimmed = line.trim();
if (trimmed === sectionHeader) {
inSection = true;
continue;
}
if (inSection && trimmed.startsWith("[")) {
break;
}
if (!inSection) {
continue;
}
const match = /^(\w+)\s*=\s*"([^"]*)"/.exec(trimmed);
if (!match) {
continue;
}
const [, key, value] = match;
if (key === "base_url") {
baseUrl = value;
} else if (key === "wire_api") {
wireApi = value;
}
}

if (!baseUrl) {
return undefined;
}
return {
api: (wireApi ? WIRE_API_MAP[wireApi] : undefined) ?? "openai-responses",
baseUrl,
};
} catch {
return undefined;
}
}
3 changes: 3 additions & 0 deletions packages/runtime/src/models/providers/openai-codex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./codex-credentials";
export * from "./config";
export * from "./provider";
Loading