From 88cef83b901124670e495d702e8fcf591458c5c7 Mon Sep 17 00:00:00 2001 From: Administrator <3625568490@qq.com> Date: Fri, 17 Apr 2026 01:49:25 +0800 Subject: [PATCH 1/2] Checkpoint OpenCodex native runtime replatform state --- .env.example | 8 +- CLAUDE.md | 36 +- bun.lock | 9 +- package.json | 35 +- resources/cli/{1code => opencodex} | 8 +- resources/cli/opencodex.cmd | 18 + scripts/check-packaging-readiness.mjs | 23 + scripts/clean-paths.mjs | 28 + scripts/download-claude-binary.mjs | 15 +- scripts/download-codex-binary.mjs | 2 +- scripts/generate-update-manifest.mjs | 31 +- scripts/patch-electron-dev.mjs | 8 +- scripts/release-config.mjs | 127 +++ scripts/release-config.test.ts | 280 ++++++ scripts/sync-to-public.sh | 39 +- scripts/upload-release.mjs | 44 + src/env.d.ts | 5 + src/main/auth-manager.test.ts | 34 + src/main/auth-manager.ts | 301 ------ src/main/index.ts | 366 +++----- src/main/lib/analytics.ts | 2 +- src/main/lib/auto-updater.ts | 12 +- src/main/lib/cli.ts | 8 +- src/main/lib/config.ts | 9 +- src/main/lib/db/index.ts | 11 +- src/main/lib/git/sandbox-import.ts | 426 --------- src/main/lib/opencodex/app-identity.test.ts | 81 ++ src/main/lib/opencodex/app-identity.ts | 94 ++ src/main/lib/opencodex/auth-url.ts | 8 + src/main/lib/opencodex/backend-config.ts | 81 ++ src/main/lib/opencodex/backend-host.test.ts | 63 ++ src/main/lib/opencodex/backend-host.ts | 331 +++++++ src/main/lib/opencodex/cloud-auth.ts | 6 + .../opencodex/desktop-shell-contract.test.ts | 79 ++ src/main/lib/opencodex/local-profile.test.ts | 59 ++ src/main/lib/opencodex/local-profile.ts | 79 ++ src/main/lib/opencodex/preflight.test.ts | 314 +++++++ src/main/lib/opencodex/preflight.ts | 403 ++++++++ src/main/lib/opencodex/startup-state.ts | 26 + src/main/lib/opencodex/update-feed.test.ts | 16 + src/main/lib/opencodex/update-feed.ts | 13 + src/main/lib/opencodex/web-routes.test.ts | 23 + src/main/lib/opencodex/web-routes.ts | 15 + src/main/lib/platform/cli-identity.test.ts | 34 + src/main/lib/platform/darwin.ts | 8 +- src/main/lib/platform/linux.ts | 8 +- src/main/lib/platform/windows.ts | 10 +- src/main/lib/terminal/env.test.ts | 15 + src/main/lib/terminal/env.ts | 3 +- .../lib/trpc/routers/anthropic-accounts.ts | 453 --------- src/main/lib/trpc/routers/chats.ts | 86 +- src/main/lib/trpc/routers/claude-code.ts | 441 --------- src/main/lib/trpc/routers/claude.ts | 4 +- src/main/lib/trpc/routers/codex.ts | 2 +- src/main/lib/trpc/routers/debug.ts | 8 +- src/main/lib/trpc/routers/index.ts | 6 +- src/main/lib/trpc/routers/opencodex.ts | 584 ++++++++++++ src/main/lib/trpc/routers/sandbox-import.ts | 711 +-------------- src/main/lib/trpc/routers/voice.ts | 193 +--- src/main/windows/main.ts | 374 +++----- src/main/windows/startup-surface.test.ts | 12 + src/main/windows/startup-surface.ts | 7 + src/preload/index.d.ts | 76 +- src/preload/index.ts | 141 ++- src/renderer/App.tsx | 235 +++-- src/renderer/app-surface.test.ts | 43 + src/renderer/app-surface.ts | 26 + src/renderer/branding.test.ts | 31 + .../components/dialogs/claude-login-modal.tsx | 434 --------- .../components/dialogs/codex-login-modal.tsx | 217 ----- .../dialogs/mcp-approval-dialog.tsx | 12 +- .../dialogs/settings-tabs/agent-dialog.tsx | 8 +- .../dialogs/settings-tabs/agents-beta-tab.tsx | 38 +- .../agents-custom-agents-tab.tsx | 4 +- .../settings-tabs/agents-debug-tab.tsx | 6 +- .../dialogs/settings-tabs/agents-mcp-tab.tsx | 229 ++--- .../settings-tabs/agents-models-tab.tsx | 424 +++------ .../settings-tabs/agents-plugins-tab.tsx | 14 +- .../settings-tabs/agents-preferences-tab.tsx | 8 +- .../settings-tabs/agents-profile-tab.tsx | 50 +- .../settings-tabs/agents-skills-tab.tsx | 10 +- .../mcp/add-mcp-server-dialog.tsx | 14 +- .../mcp/delete-server-confirm.tsx | 4 +- .../mcp/edit-mcp-server-dialog.tsx | 63 +- .../settings-tabs/mcp/mcp-server-form.tsx | 14 +- .../components/opencodex-startup-blocker.tsx | 60 ++ src/renderer/components/ui/logo.tsx | 2 +- src/renderer/components/update-banner.tsx | 160 ++-- src/renderer/components/windows-title-bar.tsx | 2 +- src/renderer/csp.test.ts | 16 + src/renderer/features/agents/atoms/index.ts | 33 +- .../components/agent-model-selector.tsx | 25 +- .../agents/components/agents-help-popover.tsx | 118 +-- .../agents/components/codex-login-content.tsx | 123 --- .../agents/hooks/use-codex-login-flow.ts | 385 -------- .../features/agents/lib/acp-chat-transport.ts | 28 +- .../features/agents/lib/ipc-chat-transport.ts | 44 +- .../agents/lib/open-codex-chat-transport.ts | 63 ++ .../agents/lib/opencodex-runtime.test.ts | 35 + .../features/agents/lib/opencodex-runtime.ts | 61 ++ .../agents/lib/remote-chat-transport.ts | 125 +-- .../features/agents/main/active-chat.tsx | 112 ++- .../features/agents/main/chat-input-area.tsx | 13 +- .../features/agents/main/new-chat-form.tsx | 23 +- .../agents/mentions/agents-file-mention.tsx | 2 +- .../features/agents/ui/agent-diff-view.tsx | 2 +- .../features/agents/ui/agent-preview.tsx | 4 +- .../features/agents/ui/agents-content.tsx | 53 +- .../agents/ui/mcp-servers-indicator.tsx | 44 +- .../_components/automation-card.tsx | 57 -- .../automations/_components/constants.ts | 43 - .../features/automations/_components/index.ts | 33 - .../automations/_components/linear-icon.tsx | 7 - .../automations/_components/platform-icon.tsx | 15 - .../automations/_components/tab-toggle.tsx | 56 -- .../automations/_components/template-card.tsx | 47 - .../automations/_components/templates.ts | 44 - .../features/automations/_components/types.ts | 34 - .../features/automations/_components/utils.ts | 24 - .../automations/automations-detail-view.tsx | 810 ---------------- .../automations/automations-styles.css | 17 - .../features/automations/automations-view.tsx | 274 ------ .../features/automations/inbox-styles.css | 17 - .../features/automations/inbox-view.tsx | 863 ------------------ src/renderer/features/automations/index.ts | 3 - .../features/details-sidebar/atoms/index.ts | 2 +- .../details-sidebar/details-sidebar.tsx | 2 +- .../details-sidebar/sections/mcp-widget.tsx | 4 +- .../features/layout/agents-layout.tsx | 48 +- .../mentions/providers/tools-provider.ts | 4 +- .../onboarding/anthropic-onboarding-page.tsx | 418 --------- .../onboarding/api-key-onboarding-page.tsx | 286 ------ .../onboarding/billing-method-page.tsx | 241 +---- .../onboarding/codex-onboarding-page.tsx | 115 --- src/renderer/features/onboarding/index.ts | 3 - .../opencodex-backend-config.test.ts | 54 ++ .../onboarding/opencodex-backend-config.ts | 79 ++ .../onboarding/opencodex-backend-editor.tsx | 393 ++++++++ .../opencodex/backend-control-view.tsx | 512 +++++++++++ .../features/settings/settings-sidebar.tsx | 8 +- .../features/sidebar/agents-sidebar.tsx | 387 +++----- src/renderer/icons/framework-icons.tsx | 4 +- src/renderer/index.html | 4 +- src/renderer/lib/analytics.ts | 2 +- src/renderer/lib/api-fetch.test.ts | 27 + src/renderer/lib/api-fetch.ts | 2 +- src/renderer/lib/atoms/index.ts | 89 +- src/renderer/lib/hooks/use-just-updated.ts | 22 +- src/renderer/lib/opencodex-links.ts | 29 + src/renderer/lib/remote-api.ts | 73 +- src/renderer/lib/remote-trpc.ts | 62 +- src/renderer/lib/updates/banner-model.test.ts | 60 ++ src/renderer/lib/updates/banner-model.ts | 76 ++ .../lib/updates/changelog-url.test.ts | 34 + src/renderer/lib/updates/changelog-url.ts | 19 + src/renderer/login.html | 58 +- src/renderer/opencodex-shell-contract.test.ts | 117 +++ tests/wor44-branding.test.mjs | 121 +++ 158 files changed, 6292 insertions(+), 9576 deletions(-) rename resources/cli/{1code => opencodex} (64%) mode change 100755 => 100644 create mode 100644 resources/cli/opencodex.cmd create mode 100644 scripts/check-packaging-readiness.mjs create mode 100644 scripts/clean-paths.mjs create mode 100644 scripts/release-config.mjs create mode 100644 scripts/release-config.test.ts create mode 100644 scripts/upload-release.mjs create mode 100644 src/main/auth-manager.test.ts delete mode 100644 src/main/auth-manager.ts delete mode 100644 src/main/lib/git/sandbox-import.ts create mode 100644 src/main/lib/opencodex/app-identity.test.ts create mode 100644 src/main/lib/opencodex/app-identity.ts create mode 100644 src/main/lib/opencodex/auth-url.ts create mode 100644 src/main/lib/opencodex/backend-config.ts create mode 100644 src/main/lib/opencodex/backend-host.test.ts create mode 100644 src/main/lib/opencodex/backend-host.ts create mode 100644 src/main/lib/opencodex/cloud-auth.ts create mode 100644 src/main/lib/opencodex/desktop-shell-contract.test.ts create mode 100644 src/main/lib/opencodex/local-profile.test.ts create mode 100644 src/main/lib/opencodex/local-profile.ts create mode 100644 src/main/lib/opencodex/preflight.test.ts create mode 100644 src/main/lib/opencodex/preflight.ts create mode 100644 src/main/lib/opencodex/startup-state.ts create mode 100644 src/main/lib/opencodex/update-feed.test.ts create mode 100644 src/main/lib/opencodex/update-feed.ts create mode 100644 src/main/lib/opencodex/web-routes.test.ts create mode 100644 src/main/lib/opencodex/web-routes.ts create mode 100644 src/main/lib/platform/cli-identity.test.ts create mode 100644 src/main/lib/terminal/env.test.ts delete mode 100644 src/main/lib/trpc/routers/anthropic-accounts.ts delete mode 100644 src/main/lib/trpc/routers/claude-code.ts create mode 100644 src/main/lib/trpc/routers/opencodex.ts create mode 100644 src/main/windows/startup-surface.test.ts create mode 100644 src/main/windows/startup-surface.ts create mode 100644 src/renderer/app-surface.test.ts create mode 100644 src/renderer/app-surface.ts create mode 100644 src/renderer/branding.test.ts delete mode 100644 src/renderer/components/dialogs/claude-login-modal.tsx delete mode 100644 src/renderer/components/dialogs/codex-login-modal.tsx create mode 100644 src/renderer/components/opencodex-startup-blocker.tsx create mode 100644 src/renderer/csp.test.ts delete mode 100644 src/renderer/features/agents/components/codex-login-content.tsx delete mode 100644 src/renderer/features/agents/hooks/use-codex-login-flow.ts create mode 100644 src/renderer/features/agents/lib/open-codex-chat-transport.ts create mode 100644 src/renderer/features/agents/lib/opencodex-runtime.test.ts create mode 100644 src/renderer/features/agents/lib/opencodex-runtime.ts delete mode 100644 src/renderer/features/automations/_components/automation-card.tsx delete mode 100644 src/renderer/features/automations/_components/constants.ts delete mode 100644 src/renderer/features/automations/_components/index.ts delete mode 100644 src/renderer/features/automations/_components/linear-icon.tsx delete mode 100644 src/renderer/features/automations/_components/platform-icon.tsx delete mode 100644 src/renderer/features/automations/_components/tab-toggle.tsx delete mode 100644 src/renderer/features/automations/_components/template-card.tsx delete mode 100644 src/renderer/features/automations/_components/templates.ts delete mode 100644 src/renderer/features/automations/_components/types.ts delete mode 100644 src/renderer/features/automations/_components/utils.ts delete mode 100644 src/renderer/features/automations/automations-detail-view.tsx delete mode 100644 src/renderer/features/automations/automations-styles.css delete mode 100644 src/renderer/features/automations/automations-view.tsx delete mode 100644 src/renderer/features/automations/inbox-styles.css delete mode 100644 src/renderer/features/automations/inbox-view.tsx delete mode 100644 src/renderer/features/automations/index.ts delete mode 100644 src/renderer/features/onboarding/anthropic-onboarding-page.tsx delete mode 100644 src/renderer/features/onboarding/api-key-onboarding-page.tsx delete mode 100644 src/renderer/features/onboarding/codex-onboarding-page.tsx create mode 100644 src/renderer/features/onboarding/opencodex-backend-config.test.ts create mode 100644 src/renderer/features/onboarding/opencodex-backend-config.ts create mode 100644 src/renderer/features/onboarding/opencodex-backend-editor.tsx create mode 100644 src/renderer/features/opencodex/backend-control-view.tsx create mode 100644 src/renderer/lib/api-fetch.test.ts create mode 100644 src/renderer/lib/opencodex-links.ts create mode 100644 src/renderer/lib/updates/banner-model.test.ts create mode 100644 src/renderer/lib/updates/banner-model.ts create mode 100644 src/renderer/lib/updates/changelog-url.test.ts create mode 100644 src/renderer/lib/updates/changelog-url.ts create mode 100644 src/renderer/opencodex-shell-contract.test.ts create mode 100644 tests/wor44-branding.test.mjs diff --git a/.env.example b/.env.example index 6293848c8..f680aab97 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,13 @@ MAIN_VITE_POSTHOG_HOST=https://us.i.posthog.com VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VITE_POSTHOG_HOST=https://us.i.posthog.com -# Feedback URL (optional - defaults to open source Discord if not set) -# Set this in hosted builds to use a private feedback channel +# OpenCodex link overrides (optional - keep product/community/changelog links OpenCodex-native) +# Community falls back to VITE_FEEDBACK_URL, then the public Discord invite # VITE_FEEDBACK_URL=https://discord.gg/your-private-invite +# VITE_OPENCODEX_COMMUNITY_URL=https://community.example.com/opencodex +# VITE_OPENCODEX_CHANGELOG_URL=https://product.example.com/opencodex/changelog +# VITE_OPENCODEX_AGENTS_CHANGELOG_URL=https://product.example.com/opencodex/agents/changelog +# VITE_OPENCODEX_CHANGELOG_FEED_URL=https://product.example.com/api/opencodex/changelog?per_page=3 # API URL (optional - defaults to https://21st.dev) # Only change this if you're running the web app locally diff --git a/CLAUDE.md b/CLAUDE.md index 724c9cd9a..d5c3bf677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What is this? -**21st Agents** - A local-first Electron desktop app for AI-powered code assistance. Users create chat sessions linked to local project folders, interact with Claude in Plan or Agent mode, and see real-time tool execution (bash, file edits, web search, etc.). +**OpenCodex** - A local-native Electron desktop coding workstation. Users create chat sessions linked to local project folders, interact through the desktop UI, and see real-time tool execution (bash, file edits, web search, etc.). ## Commands @@ -165,8 +165,8 @@ rm -rf ~/Library/Application\ Support/Agents\ Dev/ /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -kill -r -domain local -domain system -domain user # 3. Clear app preferences -defaults delete dev.21st.agents.dev # Dev mode -defaults delete dev.21st.agents # Production +defaults delete dev.opencodex.desktop.dev # Dev mode +defaults delete dev.opencodex.desktop # Production # 4. Run in dev mode with clean state cd apps/desktop @@ -178,7 +178,7 @@ bun run dev - **Folder dialog not appearing**: Window focus timing issues on first launch. Fixed by ensuring window focus before showing `dialog.showOpenDialog()`. **Dev vs Production App:** -- Dev mode uses `twentyfirst-agents-dev://` protocol +- Dev mode uses `opencodex-dev://` protocol - Dev mode uses separate userData path (`~/Library/Application Support/Agents Dev/`) - This prevents conflicts between dev and production installs @@ -186,8 +186,8 @@ bun run dev ### Prerequisites for Notarization -- Keychain profile: `21st-notarize` -- Create with: `xcrun notarytool store-credentials "21st-notarize" --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID` +- Keychain profile: `opencodex-notarize` +- Create with: `xcrun notarytool store-credentials "opencodex-notarize" --apple-id YOUR_APPLE_ID --team-id YOUR_TEAM_ID` ### Release Commands @@ -199,7 +199,8 @@ bun run release bun run build # Compile TypeScript bun run package:mac # Build & sign macOS app bun run dist:manifest # Generate latest-mac.yml manifests -./scripts/upload-release-wrangler.sh # Submit notarization & upload to R2 CDN +bun run dist:doctor # Verify build deps plus OPENCODEX_WEB_URL / OPENCODEX_UPDATE_BASE_URL +bun run dist:upload # Print the OpenCodex upload plan for the configured update target ``` ### Bump Version Before Release @@ -210,30 +211,31 @@ npm version patch --no-git-tag-version # 0.0.27 → 0.0.28 ### After Release Script Completes -1. Wait for notarization (2-5 min): `xcrun notarytool history --keychain-profile "21st-notarize"` +1. Wait for notarization (2-5 min): `xcrun notarytool history --keychain-profile "opencodex-notarize"` 2. Staple DMGs: `cd release && xcrun stapler staple *.dmg` 3. Re-upload stapled DMGs to R2 and GitHub (see RELEASE.md for commands) 4. Update changelog: `gh release edit v0.0.X --notes "..."` 5. **Upload manifests (triggers auto-updates!)** — see RELEASE.md 6. Sync to public: `./scripts/sync-to-public.sh` -### Files Uploaded to CDN +### Files Uploaded to Configured Update Target | File | Purpose | |------|---------| | `latest-mac.yml` | Manifest for arm64 auto-updates | | `latest-mac-x64.yml` | Manifest for Intel auto-updates | -| `1Code-{version}-arm64-mac.zip` | Auto-update payload (arm64) | -| `1Code-{version}-mac.zip` | Auto-update payload (Intel) | -| `1Code-{version}-arm64.dmg` | Manual download (arm64) | -| `1Code-{version}.dmg` | Manual download (Intel) | +| `OpenCodex-{version}-arm64-mac.zip` | Auto-update payload (arm64) | +| `OpenCodex-{version}-mac.zip` | Auto-update payload (Intel) | +| `OpenCodex-{version}-arm64.dmg` | Manual download (arm64) | +| `OpenCodex-{version}.dmg` | Manual download (Intel) | ### Auto-Update Flow -1. App checks `https://cdn.21st.dev/releases/desktop/latest-mac.yml` on startup and when window regains focus (with 1 min cooldown) -2. If version in manifest > current version, shows "Update Available" banner -3. User clicks Download → downloads ZIP in background -4. User clicks "Restart Now" → installs update and restarts +1. Release validation requires both `OPENCODEX_WEB_URL` and `OPENCODEX_UPDATE_BASE_URL`; packaged builds keep `.invalid` placeholders only as a non-ready safety fallback. +2. App checks the configured `OPENCODEX_UPDATE_BASE_URL` target for `latest-mac.yml` on startup and when window regains focus (with 1 min cooldown) +3. If version in manifest > current version, shows "Update Available" banner +4. User clicks Download → downloads ZIP in background +5. User clicks "Restart Now" → installs update and restarts ## Current Status (WIP) diff --git a/bun.lock b/bun.lock index f338c9b34..0865057e8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.32", + "@anthropic-ai/claude-agent-sdk": "0.2.45", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", @@ -42,7 +42,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", - "@zed-industries/codex-acp": "^0.9.3", + "@zed-industries/codex-acp": "0.9.3", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -90,6 +90,7 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@electron/rebuild": "^4.0.3", + "@tailwindcss/container-queries": "^0.1.1", "@types/better-sqlite3": "^7.6.13", "@types/diff": "^8.0.0", "@types/node": "^20.17.50", @@ -126,7 +127,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.32", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.45", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.45.tgz", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AKH2hKoJNyjLf9ThAttKqbmCjUFg7qs/8+LR/UTVX20fCLn359YH9WrQc6dAiAfi8RYNA+mWwrNYCAq+Sdo5Ag=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -648,6 +649,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "https://registry.npmmirror.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], diff --git a/package.json b/package.json index da2a5e747..771a600bb 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "21st-desktop", + "name": "opencodex-desktop", "version": "0.0.72", "private": true, - "description": "1Code - UI for parallel work with AI agents", - "homepage": "https://21st.dev", + "description": "OpenCodex - local-native desktop coding workstation", + "homepage": "", "author": { - "name": "21st.dev", - "email": "support@21st.dev" + "name": "OpenCodex", + "email": "support@opencodex.local" }, "main": "out/main/index.js", "scripts": { @@ -18,14 +18,15 @@ "package:win": "electron-builder --win", "package:linux": "electron-builder --linux", "dist": "electron-builder", + "dist:doctor": "node scripts/check-packaging-readiness.mjs", "dist:manifest": "node scripts/generate-update-manifest.mjs", "dist:upload": "node scripts/upload-release.mjs", "claude:download": "node scripts/download-claude-binary.mjs --version=2.1.45", "claude:download:all": "node scripts/download-claude-binary.mjs --version=2.1.45 --all", "codex:download": "node scripts/download-codex-binary.mjs --version=0.98.0", "codex:download:all": "node scripts/download-codex-binary.mjs --version=0.98.0 --all", - "release": "rm -rf release && bun i && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && ./scripts/upload-release-wrangler.sh", - "release:dev": "rm -rf release && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && rm -rf node_modules && bun i", + "release": "node scripts/clean-paths.mjs release && bun i && bun run dist:doctor && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun run dist:manifest && bun run dist:upload", + "release:dev": "node scripts/clean-paths.mjs release node_modules && bun run claude:download && bun run codex:download && bun run build && bun run package:mac && bun i", "sync:public": "./scripts/sync-to-public.sh", "icon:generate": "node scripts/generate-icon.mjs", "db:generate": "drizzle-kit generate", @@ -140,14 +141,14 @@ "vite": "^6.3.4" }, "build": { - "appId": "dev.21st.agents", - "productName": "1Code", + "appId": "dev.opencodex.desktop", + "productName": "OpenCodex", "npmRebuild": true, "protocols": [ { - "name": "1Code", + "name": "OpenCodex", "schemes": [ - "twentyfirst-agents" + "opencodex" ] } ], @@ -173,6 +174,14 @@ { "from": "resources/bin/VERSION", "to": "bin/VERSION" + }, + { + "from": "resources/cli/opencodex", + "to": "cli/opencodex" + }, + { + "from": "resources/cli/opencodex.cmd", + "to": "cli/opencodex.cmd" } ], "asar": true, @@ -207,7 +216,7 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "extendInfo": { - "NSMicrophoneUsageDescription": "1Code needs microphone access for voice dictation" + "NSMicrophoneUsageDescription": "OpenCodex needs microphone access for voice dictation" } }, "dmg": { @@ -253,7 +262,7 @@ }, "publish": { "provider": "generic", - "url": "https://cdn.21st.dev/releases/desktop" + "url": "${env.OPENCODEX_UPDATE_BASE_URL}" } }, "pnpm": { diff --git a/resources/cli/1code b/resources/cli/opencodex old mode 100755 new mode 100644 similarity index 64% rename from resources/cli/1code rename to resources/cli/opencodex index ef4898e24..b03581f43 --- a/resources/cli/1code +++ b/resources/cli/opencodex @@ -1,6 +1,6 @@ #!/bin/bash -# 1code CLI launcher -# Opens 1Code app with the specified directory +# OpenCodex CLI launcher +# Opens OpenCodex with the specified directory # Resolve the directory argument (default to current directory) DIR="${1:-.}" @@ -15,5 +15,5 @@ if [ -z "$DIR" ] || [ ! -d "$DIR" ]; then exit 1 fi -# Open 1Code app with the directory argument -open -a "1Code" --args "$DIR" +# Open OpenCodex with the directory argument +open -a "OpenCodex" --args "$DIR" diff --git a/resources/cli/opencodex.cmd b/resources/cli/opencodex.cmd new file mode 100644 index 000000000..54cc8be70 --- /dev/null +++ b/resources/cli/opencodex.cmd @@ -0,0 +1,18 @@ +@echo off +setlocal +set "DIR=%~1" +if "%DIR%"=="" set "DIR=." + +for %%I in ("%DIR%") do set "TARGET_DIR=%%~fI" +if not exist "%TARGET_DIR%\" ( + echo Error: Invalid directory + exit /b 1 +) + +set "APP_EXE=%LOCALAPPDATA%\Programs\OpenCodex\OpenCodex.exe" +if not exist "%APP_EXE%" ( + echo Error: OpenCodex.exe not found at "%APP_EXE%" + exit /b 1 +) + +start "" "%APP_EXE%" "%TARGET_DIR%" diff --git a/scripts/check-packaging-readiness.mjs b/scripts/check-packaging-readiness.mjs new file mode 100644 index 000000000..8d8221c5c --- /dev/null +++ b/scripts/check-packaging-readiness.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import path from "node:path" +import { fileURLToPath } from "node:url" +import { assessPackagingReadiness } from "./release-config.mjs" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const readiness = assessPackagingReadiness({ + env: process.env, + rootDir, +}) + +if (readiness.ok) { + console.log("OpenCodex packaging readiness: OK") + process.exit(0) +} + +console.log("OpenCodex packaging readiness: BLOCKED") +for (const issue of readiness.issues) { + console.log(`- ${issue}`) +} +process.exit(1) diff --git a/scripts/clean-paths.mjs b/scripts/clean-paths.mjs new file mode 100644 index 000000000..969bb9137 --- /dev/null +++ b/scripts/clean-paths.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const targets = process.argv.slice(2) + +if (targets.length === 0) { + console.error("Provide one or more relative paths to clean.") + process.exit(1) +} + +for (const target of targets) { + const normalizedTarget = path.normalize(target) + if ( + path.isAbsolute(normalizedTarget) || + normalizedTarget === ".." || + normalizedTarget.startsWith(`..${path.sep}`) + ) { + console.error(`Refusing to clean unsafe path: ${target}`) + process.exit(1) + } + + fs.rmSync(path.join(rootDir, normalizedTarget), { recursive: true, force: true }) +} diff --git a/scripts/download-claude-binary.mjs b/scripts/download-claude-binary.mjs index b41c57cd1..cde0304e8 100644 --- a/scripts/download-claude-binary.mjs +++ b/scripts/download-claude-binary.mjs @@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const ROOT_DIR = path.join(__dirname, "..") const BIN_DIR = path.join(ROOT_DIR, "resources", "bin") +const USER_AGENT = "opencodex-desktop-claude-downloader" // Claude Code distribution base URL const DIST_BASE = @@ -36,10 +37,16 @@ const PLATFORMS = { /** * Fetch JSON from URL */ +function getRequestHeaders() { + return { + "User-Agent": USER_AGENT, + } +} + function fetchJson(url) { return new Promise((resolve, reject) => { https - .get(url, (res) => { + .get(url, { headers: getRequestHeaders() }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { return fetchJson(res.headers.location).then(resolve).catch(reject) } @@ -64,7 +71,7 @@ function downloadFile(url, destPath) { const request = (url) => { https - .get(url, (res) => { + .get(url, { headers: getRequestHeaders() }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { file.close() fs.unlinkSync(destPath) @@ -136,7 +143,9 @@ async function getLatestVersion() { try { // Fetch from the same endpoint that install.sh uses - const response = await fetch("https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest") + const response = await fetch("https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest", { + headers: getRequestHeaders(), + }) if (response.ok) { const version = await response.text() return version.trim() diff --git a/scripts/download-codex-binary.mjs b/scripts/download-codex-binary.mjs index 30fd9477d..f9aa2bd0c 100644 --- a/scripts/download-codex-binary.mjs +++ b/scripts/download-codex-binary.mjs @@ -21,7 +21,7 @@ const BIN_DIR = path.join(ROOT_DIR, "resources", "bin") const RELEASE_REPO = "openai/codex" const RELEASE_TAG_PREFIX = "rust-v" -const USER_AGENT = "21st-desktop-codex-downloader" +const USER_AGENT = "opencodex-desktop-codex-downloader" const PLATFORMS = { "darwin-arm64": { diff --git a/scripts/generate-update-manifest.mjs b/scripts/generate-update-manifest.mjs index d8f73ee6a..1acb473b7 100644 --- a/scripts/generate-update-manifest.mjs +++ b/scripts/generate-update-manifest.mjs @@ -9,9 +9,7 @@ * Usage: * node scripts/generate-update-manifest.mjs * - * The script expects ZIP files to exist in the release/ directory: - * - Agents-{version}-arm64-mac.zip - * - Agents-{version}-mac.zip + * The script expects ZIP files to exist in the release/ directory using the packaged product name. * * Run this after `npm run dist` to generate the manifest files. */ @@ -20,6 +18,7 @@ import { createHash } from "crypto" import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs" import { join, dirname } from "path" import { fileURLToPath } from "url" +import { getMacArtifactNames, getManifestNextStepLines } from "./release-config.mjs" const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -40,6 +39,8 @@ const packageJson = JSON.parse( readFileSync(join(__dirname, "../package.json"), "utf-8") ) const version = process.env.VERSION || packageJson.version +const productName = packageJson.build?.productName || packageJson.name +const macArtifactNames = getMacArtifactNames({ productName, version }) const releaseDir = join(__dirname, "../release") @@ -76,9 +77,7 @@ function findReleaseFile(pattern, ext = ".zip") { * Generate manifest for a specific architecture */ function generateManifest(arch) { - // electron-builder names files differently: - // arm64: Agents-{version}-arm64-mac.zip - // x64: Agents-{version}-mac.zip + // electron-builder names files differently by architecture, but always prefixes them with the packaged product name. const pattern = arch === "arm64" ? `${version}-arm64-mac` : `${version}-mac` const zipPath = findReleaseFile(pattern, ".zip") @@ -243,18 +242,14 @@ if (!arm64Manifest && !x64Manifest && !linuxManifest) { console.log("=".repeat(50)) console.log("Manifest generation complete!") console.log() -const prefix = channel === "beta" ? "beta" : "latest" console.log("Next steps:") -console.log("1. Upload the following files to cdn.21st.dev/releases/desktop/:") -if (arm64Manifest) { - console.log(` - ${prefix}-mac.yml`) - console.log(` - Agents-${version}-arm64-mac.zip`) - console.log(` - Agents-${version}-arm64.dmg (for manual download)`) +for (const line of getManifestNextStepLines({ + env: process.env, + channel, + macArtifactNames, + hasArm64Manifest: !!arm64Manifest, + hasX64Manifest: !!x64Manifest, +})) { + console.log(line) } -if (x64Manifest) { - console.log(` - ${prefix}-mac-x64.yml`) - console.log(` - Agents-${version}-mac.zip`) - console.log(` - Agents-${version}.dmg (for manual download)`) -} -console.log("2. Create a release entry in the admin dashboard") console.log("=".repeat(50)) diff --git a/scripts/patch-electron-dev.mjs b/scripts/patch-electron-dev.mjs index cb5fe8d03..a138a5314 100644 --- a/scripts/patch-electron-dev.mjs +++ b/scripts/patch-electron-dev.mjs @@ -1,4 +1,4 @@ -// Patches the Electron.app bundle in node_modules to show "1Code" name and icon in macOS dock during dev mode. +// Patches the Electron.app bundle in node_modules to show "OpenCodex" name and icon in macOS dock during dev mode. import { execSync } from "child_process" import { copyFileSync, existsSync } from "fs" import { join, dirname } from "path" @@ -18,9 +18,9 @@ if (process.platform !== "darwin") { if (existsSync(plistPath)) { try { - execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleName 1Code" "${plistPath}"`) - execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName 1Code" "${plistPath}"`) - console.log("[patch-electron-dev] Updated Info.plist: name -> 1Code") + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleName OpenCodex" "${plistPath}"`) + execSync(`/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName OpenCodex" "${plistPath}"`) + console.log("[patch-electron-dev] Updated Info.plist: name -> OpenCodex") } catch (e) { console.warn("[patch-electron-dev] Failed to update Info.plist:", e.message) } diff --git a/scripts/release-config.mjs b/scripts/release-config.mjs new file mode 100644 index 000000000..27344a71f --- /dev/null +++ b/scripts/release-config.mjs @@ -0,0 +1,127 @@ +import fs from "node:fs" +import path from "node:path" + +export function getMacArtifactNames({ productName, version }) { + return { + arm64Zip: `${productName}-${version}-arm64-mac.zip`, + x64Zip: `${productName}-${version}-mac.zip`, + arm64Dmg: `${productName}-${version}-arm64.dmg`, + x64Dmg: `${productName}-${version}.dmg`, + } +} + +export function getConfiguredReleaseUploadTarget(env = process.env) { + const configuredBaseUrl = env.OPENCODEX_UPDATE_BASE_URL?.trim() + if (!configuredBaseUrl) { + return null + } + + return configuredBaseUrl.replace(/\/+$/, "") +} + +export function getReleaseUploadTargetLabel(env = process.env) { + return getConfiguredReleaseUploadTarget(env) || "the OPENCODEX_UPDATE_BASE_URL target" +} + +export function getConfiguredWebBaseUrl(env = process.env) { + const configuredBaseUrl = env.OPENCODEX_WEB_URL?.trim() + if (!configuredBaseUrl) { + return null + } + + return configuredBaseUrl.replace(/\/+$/, "") +} + +export function getReleaseWebTargetLabel(env = process.env) { + return getConfiguredWebBaseUrl(env) || "the OPENCODEX_WEB_URL host" +} + +export function assessPackagingReadiness({ env = process.env, rootDir }) { + const issues = [] + + for (const pkg of ["electron-vite", "electron-builder"]) { + const pkgPath = path.join(rootDir, "node_modules", pkg, "package.json") + if (!fs.existsSync(pkgPath)) { + issues.push(`Missing installed dependency: ${pkg}`) + } + } + + if (!getConfiguredReleaseUploadTarget(env)) { + issues.push("Missing required release env: OPENCODEX_UPDATE_BASE_URL") + } + + if (!getConfiguredWebBaseUrl(env)) { + issues.push("Missing required packaged web env: OPENCODEX_WEB_URL") + } + + return { + ok: issues.length === 0, + issues, + } +} + +export function getManifestNextStepLines({ + env = process.env, + channel = "latest", + macArtifactNames, + hasArm64Manifest, + hasX64Manifest, +}) { + const prefix = channel === "beta" ? "beta" : "latest" + const lines = [ + `1. Confirm packaged hosts: web=${getReleaseWebTargetLabel(env)}, updates=${getReleaseUploadTargetLabel(env)}`, + `2. Upload the following files to ${getReleaseUploadTargetLabel(env)}:`, + ] + + if (hasArm64Manifest) { + lines.push(` - ${prefix}-mac.yml`) + lines.push(` - ${macArtifactNames.arm64Zip}`) + lines.push(` - ${macArtifactNames.arm64Dmg} (for manual download)`) + } + + if (hasX64Manifest) { + lines.push(` - ${prefix}-mac-x64.yml`) + lines.push(` - ${macArtifactNames.x64Zip}`) + lines.push(` - ${macArtifactNames.x64Dmg} (for manual download)`) + } + + lines.push("3. Create a release entry in the admin dashboard") + return lines +} + +export function collectReleaseUploadPlan({ env = process.env, productName, version, releaseDir }) { + const targetBaseUrl = getConfiguredReleaseUploadTarget(env) + if (!targetBaseUrl) { + throw new Error("OpenCodex release upload requires OPENCODEX_UPDATE_BASE_URL to be set.") + } + + const macArtifacts = getMacArtifactNames({ productName, version }) + const expectedFiles = [ + "latest-linux.yml", + "latest-mac-x64.yml", + "latest-mac.yml", + `${productName}-${version}.AppImage`, + macArtifacts.arm64Zip, + macArtifacts.arm64Dmg, + macArtifacts.x64Zip, + macArtifacts.x64Dmg, + ] + + const entries = expectedFiles + .filter((fileName) => fs.existsSync(path.join(releaseDir, fileName))) + .sort((a, b) => a.localeCompare(b)) + .map((fileName) => ({ + fileName, + sourcePath: path.join(releaseDir, fileName), + targetUrl: `${targetBaseUrl}/${fileName}`, + })) + + if (entries.length === 0) { + throw new Error(`No release artifacts found in ${releaseDir}. Run the packaging and manifest steps first.`) + } + + return { + targetBaseUrl, + entries, + } +} \ No newline at end of file diff --git a/scripts/release-config.test.ts b/scripts/release-config.test.ts new file mode 100644 index 000000000..6559f9895 --- /dev/null +++ b/scripts/release-config.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { + assessPackagingReadiness, + collectReleaseUploadPlan, + getConfiguredWebBaseUrl, + getMacArtifactNames, + getManifestNextStepLines, + getReleaseUploadTargetLabel, + getReleaseWebTargetLabel, +} from "./release-config.mjs" + +const tempRoots: string[] = [] +const codexDownloaderSource = readFileSync( + join(import.meta.dir, "download-codex-binary.mjs"), + "utf-8", +) +const claudeDownloaderSource = readFileSync( + join(import.meta.dir, "download-claude-binary.mjs"), + "utf-8", +) +const syncToPublicSource = readFileSync( + join(import.meta.dir, "sync-to-public.sh"), + "utf-8", +) +const legacyCliLauncherPath = join(import.meta.dir, "..", "resources", "cli", "1code") + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function createTempReleaseDir(): string { + const root = mkdtempSync(join(tmpdir(), "opencodex-release-")) + tempRoots.push(root) + return root +} + +const packageJson = JSON.parse( + readFileSync(join(import.meta.dir, "..", "package.json"), "utf-8"), +) as { + name: string + description: string + author?: { name?: string } + build?: { + appId?: string + productName?: string + protocols?: Array<{ name?: string; schemes?: string[] }> + publish?: { url?: string } + extraResources?: Array<{ from?: string; to?: string }> + } +} + +describe("release packaging identity", () => { + test("package metadata uses OpenCodex-owned identifiers", () => { + expect(packageJson.name).toBe("opencodex-desktop") + expect(packageJson.description).toContain("OpenCodex") + expect(packageJson.author?.name).toBe("OpenCodex") + expect(packageJson.build?.appId).toBe("dev.opencodex.desktop") + expect(packageJson.build?.productName).toBe("OpenCodex") + expect(packageJson.build?.protocols).toEqual([ + { + name: "OpenCodex", + schemes: ["opencodex"], + }, + ]) + expect(packageJson.build?.publish?.url).toBe("${env.OPENCODEX_UPDATE_BASE_URL}") + }) + + test("release packaging includes only the OpenCodex CLI launchers in extra resources", () => { + expect(packageJson.build?.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "resources/cli/opencodex", + to: "cli/opencodex", + }), + expect.objectContaining({ + from: "resources/cli/opencodex.cmd", + to: "cli/opencodex.cmd", + }), + ]), + ) + expect(packageJson.build?.extraResources).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "resources/cli", + to: "cli", + }), + ]), + ) + }) + + test("release script uses the OpenCodex doctor and upload flow", () => { + expect(packageJson.scripts?.release).toContain("bun run dist:doctor") + expect(packageJson.scripts?.release).toContain("bun run dist:upload") + expect(packageJson.scripts?.release).not.toContain("upload-release-wrangler.sh") + }) + + test("release scripts avoid Unix-only rm -rf cleanup", () => { + expect(packageJson.scripts?.release).not.toContain("rm -rf") + expect(packageJson.scripts?.["release:dev"]).not.toContain("rm -rf") + }) + + test("codex downloader uses an OpenCodex-owned user-agent", () => { + expect(codexDownloaderSource).toContain('const USER_AGENT = "opencodex-desktop-codex-downloader"') + expect(codexDownloaderSource).not.toContain('21st-desktop-codex-downloader') + }) + + test("claude downloader uses an OpenCodex-owned user-agent", () => { + expect(claudeDownloaderSource).toContain('const USER_AGENT = "opencodex-desktop-claude-downloader"') + expect(claudeDownloaderSource).toContain('"User-Agent": USER_AGENT') + }) + + test("sync-to-public script requires explicit OpenCodex repo targets", () => { + expect(syncToPublicSource).toContain('OPENCODEX_PUBLIC_REPO_GIT') + expect(syncToPublicSource).toContain('OPENCODEX_PUBLIC_REPO_HTTPS') + expect(syncToPublicSource).toContain('OPENCODEX_PRIVATE_REPO') + expect(syncToPublicSource).not.toContain('git@github.com:21st-dev/1code.git') + expect(syncToPublicSource).not.toContain('https://github.com/21st-dev/1code') + expect(syncToPublicSource).not.toContain('21st-dev/21st') + }) + + test("legacy 1code cli launcher resource is removed", () => { + expect(require('fs').existsSync(legacyCliLauncherPath)).toBe(false) + }) + + test("release artifact names follow the packaged product name", () => { + expect(getMacArtifactNames({ productName: "OpenCodex", version: "1.2.3" })).toEqual({ + arm64Zip: "OpenCodex-1.2.3-arm64-mac.zip", + x64Zip: "OpenCodex-1.2.3-mac.zip", + arm64Dmg: "OpenCodex-1.2.3-arm64.dmg", + x64Dmg: "OpenCodex-1.2.3.dmg", + }) + }) + + test("release upload instructions use the explicit OpenCodex update target when present", () => { + expect( + getReleaseUploadTargetLabel({ + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop/", + }), + ).toBe("https://updates.opencodex.test/releases/desktop") + }) + + test("release upload instructions call out the required OpenCodex update target when unset", () => { + expect(getReleaseUploadTargetLabel({})).toBe("the OPENCODEX_UPDATE_BASE_URL target") + }) + + test("release web-host instructions use the explicit OpenCodex web host when present", () => { + expect(getConfiguredWebBaseUrl({ OPENCODEX_WEB_URL: " https://desktop.opencodex.test/root/ " })).toBe( + "https://desktop.opencodex.test/root", + ) + expect(getReleaseWebTargetLabel({ OPENCODEX_WEB_URL: "https://desktop.opencodex.test/root/" })).toBe( + "https://desktop.opencodex.test/root", + ) + }) + + test("release web-host instructions call out the required OpenCodex web host when unset", () => { + expect(getConfiguredWebBaseUrl({})).toBeNull() + expect(getReleaseWebTargetLabel({})).toBe("the OPENCODEX_WEB_URL host") + }) + + test("manifest next-step guidance includes both configured hosts and packaged artifacts", () => { + expect( + getManifestNextStepLines({ + env: { + OPENCODEX_WEB_URL: "https://desktop.opencodex.test", + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop", + }, + channel: "latest", + macArtifactNames: getMacArtifactNames({ productName: "OpenCodex", version: "1.2.3" }), + hasArm64Manifest: true, + hasX64Manifest: true, + }), + ).toEqual([ + "1. Confirm packaged hosts: web=https://desktop.opencodex.test, updates=https://updates.opencodex.test/releases/desktop", + "2. Upload the following files to https://updates.opencodex.test/releases/desktop:", + " - latest-mac.yml", + " - OpenCodex-1.2.3-arm64-mac.zip", + " - OpenCodex-1.2.3-arm64.dmg (for manual download)", + " - latest-mac-x64.yml", + " - OpenCodex-1.2.3-mac.zip", + " - OpenCodex-1.2.3.dmg (for manual download)", + "3. Create a release entry in the admin dashboard", + ]) + }) + + test("collects a release upload plan for generated manifests and artifacts", () => { + const releaseDir = createTempReleaseDir() + for (const fileName of [ + "latest-mac.yml", + "latest-mac-x64.yml", + "latest-linux.yml", + "OpenCodex-1.2.3-arm64-mac.zip", + "OpenCodex-1.2.3-mac.zip", + "OpenCodex-1.2.3-arm64.dmg", + "OpenCodex-1.2.3.dmg", + "OpenCodex-1.2.3.AppImage", + ]) { + writeFileSync(join(releaseDir, fileName), fileName, "utf-8") + } + + const plan = collectReleaseUploadPlan({ + env: { + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop/", + }, + productName: "OpenCodex", + version: "1.2.3", + releaseDir, + }) + + expect(plan.targetBaseUrl).toBe("https://updates.opencodex.test/releases/desktop") + expect(plan.entries.map((entry) => entry.fileName)).toEqual([ + "latest-linux.yml", + "latest-mac-x64.yml", + "latest-mac.yml", + "OpenCodex-1.2.3-arm64-mac.zip", + "OpenCodex-1.2.3-arm64.dmg", + "OpenCodex-1.2.3-mac.zip", + "OpenCodex-1.2.3.AppImage", + "OpenCodex-1.2.3.dmg", + ]) + expect(plan.entries[0]?.targetUrl).toBe( + "https://updates.opencodex.test/releases/desktop/latest-linux.yml", + ) + }) + + test("requires an explicit OpenCodex update target for upload planning", () => { + const releaseDir = createTempReleaseDir() + writeFileSync(join(releaseDir, "latest-mac.yml"), "manifest", "utf-8") + + expect(() => + collectReleaseUploadPlan({ + env: {}, + productName: "OpenCodex", + version: "1.2.3", + releaseDir, + }), + ).toThrow(/OPENCODEX_UPDATE_BASE_URL/i) + }) + + test("reports missing build-tool dependencies and update target as packaging-readiness blockers", () => { + const rootDir = createTempReleaseDir() + + const readiness = assessPackagingReadiness({ + env: {}, + rootDir, + }) + + expect(readiness.ok).toBe(false) + expect(readiness.issues).toEqual([ + "Missing installed dependency: electron-vite", + "Missing installed dependency: electron-builder", + "Missing required release env: OPENCODEX_UPDATE_BASE_URL", + "Missing required packaged web env: OPENCODEX_WEB_URL", + ]) + }) + + test("reports packaging readiness once build tools and update target are available", () => { + const rootDir = createTempReleaseDir() + for (const pkg of ["electron-vite", "electron-builder"]) { + const pkgDir = join(rootDir, "node_modules", pkg) + require("fs").mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: pkg }, null, 2), "utf-8") + } + + const readiness = assessPackagingReadiness({ + env: { + OPENCODEX_UPDATE_BASE_URL: "https://updates.opencodex.test/releases/desktop", + OPENCODEX_WEB_URL: "https://desktop.opencodex.test", + }, + rootDir, + }) + + expect(readiness.ok).toBe(true) + expect(readiness.issues).toEqual([]) + }) +}) \ No newline at end of file diff --git a/scripts/sync-to-public.sh b/scripts/sync-to-public.sh index 9acfa6394..df9c637a7 100755 --- a/scripts/sync-to-public.sh +++ b/scripts/sync-to-public.sh @@ -1,27 +1,46 @@ -#!/bin/bash +#!/bin/bash set -e -# Sync desktop app to public 1code repository +# Sync desktop app to the public OpenCodex repository mirror # Usage: ./scripts/sync-to-public.sh # +# Required env: +# OPENCODEX_PUBLIC_REPO_GIT git remote URL for the public mirror +# OPENCODEX_PUBLIC_REPO_HTTPS GitHub HTTPS repo URL for gh release operations +# OPENCODEX_PRIVATE_REPO source repo slug for release-note lookup +# # This script: # 1. Syncs code from private repo to public repo # 2. Creates a GitHub release in public repo with same notes as private repo +require_env() { + local name="$1" + local value="${!name}" + if [ -z "$value" ]; then + echo "Missing required env: $name" >&2 + exit 1 + fi +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DESKTOP_DIR="$(dirname "$SCRIPT_DIR")" ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)" VERSION=$(node -p "require('$DESKTOP_DIR/package.json').version") TAG="v$VERSION" -PUBLIC_REPO="git@github.com:21st-dev/1code.git" -PUBLIC_REPO_HTTPS="https://github.com/21st-dev/1code" -PRIVATE_REPO="21st-dev/21st" -TEMP_DIR="/tmp/1code-sync-$$" +require_env OPENCODEX_PUBLIC_REPO_GIT +require_env OPENCODEX_PUBLIC_REPO_HTTPS +require_env OPENCODEX_PRIVATE_REPO + +PUBLIC_REPO="$OPENCODEX_PUBLIC_REPO_GIT" +PUBLIC_REPO_HTTPS="$OPENCODEX_PUBLIC_REPO_HTTPS" +PRIVATE_REPO="$OPENCODEX_PRIVATE_REPO" +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/opencodex-sync-XXXXXX") echo "🔄 Syncing desktop app to public repository..." echo " Version: $VERSION" echo " Tag: $TAG" +echo " Public repo: $PUBLIC_REPO_HTTPS" echo "" # Get release notes from private repo @@ -31,7 +50,7 @@ RELEASE_NOTES=$(gh release view "$TAG" --repo "$PRIVATE_REPO" --json body -q '.b if [ -z "$RELEASE_NOTES" ]; then echo "⚠️ No release found for $TAG in private repo" echo " Please create a release in the private repo first:" - echo " gh release create $TAG --title \"1Code $TAG\" --notes \"...\"" + echo " gh release create $TAG --repo \"$PRIVATE_REPO\" --title \"OpenCodex $TAG\" --notes \"...\"" echo "" read -p "Continue without release notes? (y/N) " -n 1 -r echo @@ -77,7 +96,7 @@ test-electron.js # Exclude internal release docs (contains credentials, CDN URLs) RELEASE.md -scripts/upload-release-wrangler.sh +scripts/upload-release.mjs # Exclude wrangler local state (large R2 blobs) .wrangler @@ -109,7 +128,7 @@ if gh release view "$TAG" --repo "$PUBLIC_REPO_HTTPS" &>/dev/null; then echo " Release $TAG already exists, updating..." gh release edit "$TAG" \ --repo "$PUBLIC_REPO_HTTPS" \ - --title "1Code $TAG" \ + --title "OpenCodex $TAG" \ --notes "$RELEASE_NOTES" else echo " Creating new release $TAG..." @@ -119,7 +138,7 @@ else gh release create "$TAG" \ --repo "$PUBLIC_REPO_HTTPS" \ - --title "1Code $TAG" \ + --title "OpenCodex $TAG" \ --notes "$RELEASE_NOTES" fi diff --git a/scripts/upload-release.mjs b/scripts/upload-release.mjs new file mode 100644 index 000000000..3df95c35d --- /dev/null +++ b/scripts/upload-release.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { collectReleaseUploadPlan } from "./release-config.mjs" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.join(__dirname, "..") +const packageJson = JSON.parse(readFileSync(path.join(rootDir, "package.json"), "utf-8")) +const releaseDir = path.join(rootDir, "release") +const productName = packageJson.build?.productName || packageJson.name +const version = process.env.VERSION || packageJson.version + +try { + const plan = collectReleaseUploadPlan({ + env: process.env, + productName, + version, + releaseDir, + }) + + console.log("=".repeat(50)) + console.log("OpenCodex Release Upload Plan") + console.log("=".repeat(50)) + console.log(`Version: ${version}`) + console.log(`Target: ${plan.targetBaseUrl}`) + console.log(`Artifacts: ${plan.entries.length}`) + console.log() + + for (const entry of plan.entries) { + console.log(`${entry.fileName}`) + console.log(` from: ${entry.sourcePath}`) + console.log(` to: ${entry.targetUrl}`) + } + + console.log() + console.log("Upload transport is deployment-specific.") + console.log("Use this plan with the publisher for your configured update target.") + console.log("=".repeat(50)) +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/src/env.d.ts b/src/env.d.ts index b80a04255..5cc9b84a9 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -11,6 +11,11 @@ declare global { // Renderer process (VITE_ prefix) readonly VITE_POSTHOG_KEY?: string readonly VITE_POSTHOG_HOST?: string + readonly VITE_FEEDBACK_URL?: string + readonly VITE_OPENCODEX_COMMUNITY_URL?: string + readonly VITE_OPENCODEX_CHANGELOG_URL?: string + readonly VITE_OPENCODEX_AGENTS_CHANGELOG_URL?: string + readonly VITE_OPENCODEX_CHANGELOG_FEED_URL?: string } } diff --git a/src/main/auth-manager.test.ts b/src/main/auth-manager.test.ts new file mode 100644 index 000000000..4172a551a --- /dev/null +++ b/src/main/auth-manager.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { resolveOpenCodexAuthApiBaseUrl } from "./lib/opencodex/auth-url" +import { resolveOpenCodexCloudAuthEnabled } from "./lib/opencodex/cloud-auth" + +describe("resolveOpenCodexAuthApiBaseUrl", () => { + test("uses the OpenCodex web base-url seam for packaged and dev auth calls", () => { + expect(resolveOpenCodexAuthApiBaseUrl({ isPackaged: true, env: {} })).toBe("https://opencodex.invalid") + expect( + resolveOpenCodexAuthApiBaseUrl({ + isPackaged: true, + env: { OPENCODEX_WEB_URL: " https://desktop.opencodex.example/root/ " }, + }), + ).toBe("https://desktop.opencodex.example/root") + expect( + resolveOpenCodexAuthApiBaseUrl({ + isPackaged: false, + env: { MAIN_VITE_API_URL: " https://dev-api.opencodex.example/base/ " }, + }), + ).toBe("https://dev-api.opencodex.example/base") + }) +}) + +describe("resolveOpenCodexCloudAuthEnabled", () => { + test("defaults OpenCodex to local-native mode", () => { + expect(resolveOpenCodexCloudAuthEnabled({})).toBe(false) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "" })).toBe(false) + }) + + test("only enables cloud auth when explicitly requested", () => { + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "1" })).toBe(true) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "true" })).toBe(true) + expect(resolveOpenCodexCloudAuthEnabled({ OPENCODEX_ENABLE_CLOUD_AUTH: "0" })).toBe(false) + }) +}) diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts deleted file mode 100644 index e31b7bc1b..000000000 --- a/src/main/auth-manager.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { AuthStore, AuthData, AuthUser } from "./auth-store" -import { app, BrowserWindow } from "electron" -import { AUTH_SERVER_PORT } from "./constants" - -// Get API URL - in packaged app always use production, in dev allow override -function getApiBaseUrl(): string { - if (app.isPackaged) { - return "https://21st.dev" - } - return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" -} - -export class AuthManager { - private store: AuthStore - private refreshTimer?: NodeJS.Timeout - private isDev: boolean - private onTokenRefresh?: (authData: AuthData) => void - - constructor(isDev: boolean = false) { - this.store = new AuthStore(app.getPath("userData")) - this.isDev = isDev - - // Schedule refresh if already authenticated - if (this.store.isAuthenticated()) { - this.scheduleRefresh() - } - } - - /** - * Set callback to be called when token is refreshed - * This allows the main process to update cookies when tokens change - */ - setOnTokenRefresh(callback: (authData: AuthData) => void): void { - this.onTokenRefresh = callback - } - - private getApiUrl(): string { - return getApiBaseUrl() - } - - /** - * Exchange auth code for session tokens - * Called after receiving code via deep link - */ - async exchangeCode(code: string): Promise { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/exchange`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - code, - deviceInfo: this.getDeviceInfo(), - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Exchange failed: ${response.status}`) - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - return authData - } - - /** - * Get device info for session tracking - */ - private getDeviceInfo(): string { - const platform = process.platform - const arch = process.arch - const version = app.getVersion() - return `21st Desktop ${version} (${platform} ${arch})` - } - - /** - * Get a valid token, refreshing if necessary - */ - async getValidToken(): Promise { - if (!this.store.isAuthenticated()) { - return null - } - - if (this.store.needsRefresh()) { - await this.refresh() - } - - return this.store.getToken() - } - - /** - * Refresh the current session - */ - async refresh(): Promise { - const refreshToken = this.store.getRefreshToken() - if (!refreshToken) { - console.warn("No refresh token available") - return false - } - - try { - const response = await fetch(`${this.getApiUrl()}/api/auth/desktop/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }) - - if (!response.ok) { - console.error("Refresh failed:", response.status) - // If refresh fails, clear auth and require re-login - if (response.status === 401) { - this.logout() - } - return false - } - - const data = await response.json() - - const authData: AuthData = { - token: data.token, - refreshToken: data.refreshToken, - expiresAt: data.expiresAt, - user: data.user, - } - - this.store.save(authData) - this.scheduleRefresh() - - // Notify callback about token refresh (so cookie can be updated) - if (this.onTokenRefresh) { - this.onTokenRefresh(authData) - } - - return true - } catch (error) { - console.error("Refresh error:", error) - return false - } - } - - /** - * Schedule token refresh before expiration - */ - private scheduleRefresh(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - } - - const authData = this.store.load() - if (!authData) return - - const expiresAt = new Date(authData.expiresAt).getTime() - const now = Date.now() - - // Refresh 5 minutes before expiration - const refreshIn = Math.max(0, expiresAt - now - 5 * 60 * 1000) - - this.refreshTimer = setTimeout(() => { - this.refresh() - }, refreshIn) - - console.log(`Scheduled token refresh in ${Math.round(refreshIn / 1000 / 60)} minutes`) - } - - /** - * Check if user is authenticated - */ - isAuthenticated(): boolean { - return this.store.isAuthenticated() - } - - /** - * Get current user - */ - getUser(): AuthUser | null { - return this.store.getUser() - } - - /** - * Get current auth data - */ - getAuth(): AuthData | null { - return this.store.load() - } - - /** - * Logout and clear stored credentials - */ - logout(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer) - this.refreshTimer = undefined - } - this.store.clear() - } - - /** - * Start auth flow by opening browser - */ - startAuthFlow(mainWindow: BrowserWindow | null): void { - const { shell } = require("electron") - - let authUrl = `${this.getApiUrl()}/auth/desktop?auto=true` - - // In dev mode, use localhost callback (we run HTTP server on AUTH_SERVER_PORT) - // Also pass the protocol so web knows which deep link to use as fallback - if (this.isDev) { - authUrl += `&callback=${encodeURIComponent(`http://localhost:${AUTH_SERVER_PORT}/auth/callback`)}` - // Pass dev protocol so production web can use correct deep link if callback fails - authUrl += `&protocol=twentyfirst-agents-dev` - } - - shell.openExternal(authUrl) - } - - /** - * Update user profile on server and locally - */ - async updateUser(updates: { name?: string }): Promise { - const token = await this.getValidToken() - if (!token) { - throw new Error("Not authenticated") - } - - // Update on server using X-Desktop-Token header - const response = await fetch(`${this.getApiUrl()}/api/user/profile`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-Desktop-Token": token, - }, - body: JSON.stringify({ - display_name: updates.name, - }), - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })) - throw new Error(error.error || `Update failed: ${response.status}`) - } - - // Update locally - return this.store.updateUser({ name: updates.name ?? null }) - } - - /** - * Fetch user's subscription plan from web backend - * Used for PostHog analytics enrichment - */ - async fetchUserPlan(): Promise<{ email: string; plan: string; status: string | null } | null> { - const token = await this.getValidToken() - if (!token) return null - - try { - const response = await fetch(`${this.getApiUrl()}/api/desktop/user/plan`, { - headers: { "X-Desktop-Token": token }, - }) - - if (!response.ok) { - console.error("[AuthManager] Failed to fetch user plan:", response.status) - return null - } - - return response.json() - } catch (error) { - console.error("[AuthManager] Failed to fetch user plan:", error) - return null - } - } -} - -// Global singleton instance -let authManagerInstance: AuthManager | null = null - -/** - * Initialize the global auth manager instance - * Must be called once from main process initialization - */ -export function initAuthManager(isDev: boolean = false): AuthManager { - if (!authManagerInstance) { - authManagerInstance = new AuthManager(isDev) - } - return authManagerInstance -} - -/** - * Get the global auth manager instance - * Returns null if not initialized - */ -export function getAuthManager(): AuthManager | null { - return authManagerInstance -} diff --git a/src/main/index.ts b/src/main/index.ts index 57af873f0..6d862f7d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,12 @@ import * as Sentry from "@sentry/electron/main" -import { app, BrowserWindow, dialog, Menu, nativeImage, session } from "electron" +import { app, BrowserWindow, dialog, Menu, nativeImage } from "electron" import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs" import { createServer } from "http" import { join } from "path" -import { AuthManager, initAuthManager, getAuthManager as getAuthManagerFromModule } from "./auth-manager" import { - identify, initAnalytics, - setSubscriptionPlan, shutdown as shutdownAnalytics, trackAppOpened, - trackAuthCompleted, } from "./lib/analytics" import { checkForUpdates, @@ -19,6 +15,13 @@ import { setupFocusUpdateCheck, } from "./lib/auto-updater" import { closeDatabase, initDatabase } from "./lib/db" +import { runOpenCodexPackagingPreflight } from "./lib/opencodex/preflight" +import { + maybeStartOpenCodexBackendHost, + stopOpenCodexBackendHost, +} from "./lib/opencodex/backend-host" +import { setOpenCodexStartupState } from "./lib/opencodex/startup-state" +import { getOpenCodexAppUserModelId, getOpenCodexAuthPageTitle, getOpenCodexCopyright, getOpenCodexProtocol, getOpenCodexSupportUrl, getOpenCodexWebAppUrl, getOpenCodexWebBaseUrl, OPENCODEX_PRODUCT_NAME } from "./lib/opencodex/app-identity" import { getLaunchDirectory, isCliInstalled, @@ -37,13 +40,12 @@ import { getAllWindows, setIsQuitting, } from "./windows/main" -import { windowManager } from "./windows/window-manager" import { IS_DEV, AUTH_SERVER_PORT } from "./constants" // Deep link protocol (must match package.json build.protocols.schemes) // Use different protocol in dev to avoid conflicts with production app -const PROTOCOL = IS_DEV ? "twentyfirst-agents-dev" : "twentyfirst-agents" +const PROTOCOL = getOpenCodexProtocol(IS_DEV) // Set dev mode userData path BEFORE requestSingleInstanceLock() // This ensures dev and prod have separate instance locks @@ -81,108 +83,18 @@ if (app.isPackaged && !IS_DEV) { // In packaged app, ALWAYS use production URL to prevent localhost leaking into releases // In dev mode, allow override via MAIN_VITE_API_URL env variable export function getBaseUrl(): string { - if (app.isPackaged) { - return "https://21st.dev" - } - return import.meta.env.MAIN_VITE_API_URL || "https://21st.dev" + return getOpenCodexWebBaseUrl({ + OPENCODEX_WEB_URL: process.env.OPENCODEX_WEB_URL, + MAIN_VITE_API_URL: import.meta.env.MAIN_VITE_API_URL, + }) } export function getAppUrl(): string { - return process.env.ELECTRON_RENDERER_URL || "https://21st.dev/agents" -} - -// Auth manager singleton (use the one from auth-manager module) -let authManager: AuthManager - -export function getAuthManager(): AuthManager { - // First try to get from module, fallback to local variable for backwards compat - return getAuthManagerFromModule() || authManager -} - -// Handle auth code from deep link (exported for IPC handlers) -export async function handleAuthCode(code: string): Promise { - console.log("[Auth] Handling auth code:", code.slice(0, 8) + "...") - - try { - const authData = await authManager.exchangeCode(code) - console.log("[Auth] Success for user:", authData.user.email) - - // Track successful authentication - trackAuthCompleted(authData.user.id, authData.user.email) - - // Fetch and set subscription plan for analytics - try { - const planData = await authManager.fetchUserPlan() - if (planData) { - setSubscriptionPlan(planData.plan) - } - } catch (e) { - console.warn("[Auth] Failed to fetch user plan for analytics:", e) - } - - // Set desktop token cookie using persist:main partition - const ses = session.fromPartition("persist:main") - try { - // First remove any existing cookie to avoid HttpOnly conflict - await ses.cookies.remove(getBaseUrl(), "x-desktop-token") - await ses.cookies.set({ - url: getBaseUrl(), - name: "x-desktop-token", - value: authData.token, - expirationDate: Math.floor( - new Date(authData.expiresAt).getTime() / 1000, - ), - httpOnly: false, - secure: getBaseUrl().startsWith("https"), - sameSite: "lax" as const, - }) - console.log("[Auth] Desktop token cookie set") - } catch (cookieError) { - // Cookie setting is optional - auth data is already saved to disk - console.warn("[Auth] Cookie set failed (non-critical):", cookieError) - } - - // Notify all windows and reload them to show app - const windows = getAllWindows() - for (const win of windows) { - try { - if (win.isDestroyed()) continue - win.webContents.send("auth:success", authData.user) - - // Use stable window ID (main, window-2, etc.) instead of Electron's numeric ID - const stableId = windowManager.getStableId(win) - - if (process.env.ELECTRON_RENDERER_URL) { - // Pass window ID via query param for dev mode - const url = new URL(process.env.ELECTRON_RENDERER_URL) - url.searchParams.set("windowId", stableId) - win.loadURL(url.toString()) - } else { - // Pass window ID via hash for production - win.loadFile(join(__dirname, "../renderer/index.html"), { - hash: `windowId=${stableId}`, - }) - } - } catch (error) { - // Window may have been destroyed during iteration - console.warn("[Auth] Failed to reload window:", error) - } - } - // Focus the first window - windows[0]?.focus() - } catch (error) { - console.error("[Auth] Exchange failed:", error) - // Broadcast auth error to all windows (not just focused) - for (const win of getAllWindows()) { - try { - if (!win.isDestroyed()) { - win.webContents.send("auth:error", (error as Error).message) - } - } catch { - // Window destroyed during iteration - } - } - } + return getOpenCodexWebAppUrl({ + OPENCODEX_WEB_URL: process.env.OPENCODEX_WEB_URL, + MAIN_VITE_API_URL: import.meta.env.MAIN_VITE_API_URL, + ELECTRON_RENDERER_URL: process.env.ELECTRON_RENDERER_URL, + }) } // Handle deep link @@ -192,16 +104,7 @@ function handleDeepLink(url: string): void { try { const parsed = new URL(url) - // Handle auth callback: twentyfirst-agents://auth?code=xxx - if (parsed.pathname === "/auth" || parsed.host === "auth") { - const code = parsed.searchParams.get("code") - if (code) { - handleAuthCode(code) - return - } - } - - // Handle MCP OAuth callback: twentyfirst-agents://mcp-oauth?code=xxx&state=yyy + // Handle MCP OAuth callback: opencodex://mcp-oauth?code=xxx&state=yyy if (parsed.pathname === "/mcp-oauth" || parsed.host === "mcp-oauth") { const code = parsed.searchParams.get("code") const state = parsed.searchParams.get("state") @@ -287,8 +190,8 @@ console.log("[Protocol] =============================================") const FAVICON_SVG = `` const FAVICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}` -// Start local HTTP server for auth callbacks -// This catches http://localhost:{AUTH_SERVER_PORT}/auth/callback?code=xxx and /callback (for MCP OAuth) +// Start local HTTP server for MCP OAuth callbacks. +// This catches http://localhost:{AUTH_SERVER_PORT}/callback?code=xxx&state=yyy. const server = createServer((req, res) => { const url = new URL(req.url || "", `http://localhost:${AUTH_SERVER_PORT}`) @@ -299,87 +202,7 @@ const server = createServer((req, res) => { return } - if (url.pathname === "/auth/callback") { - const code = url.searchParams.get("code") - console.log( - "[Auth Server] Received callback with code:", - code?.slice(0, 8) + "...", - ) - - if (code) { - // Handle the auth code - handleAuthCode(code) - - // Send success response and close the browser tab - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(` - - - - - 1Code - Authentication - - - -
- -

Authentication successful

-

You can close this tab

-
- - -`) - } else { - res.writeHead(400, { "Content-Type": "text/plain" }) - res.end("Missing code parameter") - } - } else if (url.pathname === "/callback") { + if (url.pathname === "/callback") { // Handle MCP OAuth callback const code = url.searchParams.get("code") const state = url.searchParams.get("state") @@ -401,7 +224,7 @@ const server = createServer((req, res) => { - 1Code - MCP Authentication + ${getOpenCodexAuthPageTitle("MCP Authentication")} @@ -91,33 +92,10 @@ - +
OpenCodex local backend
+

+ This desktop shell no longer uses cloud sign-in. Start or repair the local OpenHarness backend from the main OpenCodex onboarding flow. +

- diff --git a/src/renderer/opencodex-shell-contract.test.ts b/src/renderer/opencodex-shell-contract.test.ts new file mode 100644 index 000000000..d336c111b --- /dev/null +++ b/src/renderer/opencodex-shell-contract.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, readFileSync } from "node:fs" +import { join } from "node:path" + +function readUtf8(relativePath: string): string { + return readFileSync(join(import.meta.dir, relativePath), "utf8") +} + +describe("OpenCodex renderer shell contract", () => { + test("renderer stops requesting cloud-auth desktop identity", () => { + const appSource = readUtf8("App.tsx") + const layoutSource = readUtf8("features/layout/agents-layout.tsx") + const profileTabSource = readUtf8("components/dialogs/settings-tabs/agents-profile-tab.tsx") + const modelsTabSource = readUtf8("components/dialogs/settings-tabs/agents-models-tab.tsx") + const mcpTabSource = readUtf8("components/dialogs/settings-tabs/agents-mcp-tab.tsx") + const mcpIndicatorSource = readUtf8("features/agents/ui/mcp-servers-indicator.tsx") + const activeChatSource = readUtf8("features/agents/main/active-chat.tsx") + const acpTransportSource = readUtf8("features/agents/lib/acp-chat-transport.ts") + const ipcTransportSource = readUtf8("features/agents/lib/ipc-chat-transport.ts") + const backendEditorSource = readUtf8("features/onboarding/opencodex-backend-editor.tsx") + const billingPageSource = readUtf8("features/onboarding/billing-method-page.tsx") + const onboardingIndexSource = readUtf8("features/onboarding/index.ts") + const sidebarSource = readUtf8("features/sidebar/agents-sidebar.tsx") + const agentsContentSource = readUtf8("features/agents/ui/agents-content.tsx") + const betaTabSource = readUtf8("components/dialogs/settings-tabs/agents-beta-tab.tsx") + const backendControlSource = readUtf8("features/opencodex/backend-control-view.tsx") + const rendererAtomsSource = readUtf8("lib/atoms/index.ts") + const agentsAtomsSource = readUtf8("features/agents/atoms/index.ts") + const loginPageSource = readUtf8("login.html") + const codexLoginHookPath = join( + import.meta.dir, + "features/agents/hooks/use-codex-login-flow.ts", + ) + const codexLoginContentPath = join( + import.meta.dir, + "features/agents/components/codex-login-content.tsx", + ) + + expect(appSource).not.toContain("window.desktopApi?.getUser") + expect(layoutSource).not.toContain("window.desktopApi.getUser()") + expect(layoutSource).not.toContain("window.desktopApi?.logout") + expect(layoutSource).not.toContain("ClaudeLoginModal") + expect(layoutSource).not.toContain("CodexLoginModal") + expect(layoutSource).not.toContain("claudeLoginModalConfigAtom") + expect(profileTabSource).not.toContain("window.desktopApi?.getUser") + expect(profileTabSource).not.toContain("window.desktopApi?.updateUser") + expect(modelsTabSource).not.toContain("trpc.codex.") + expect(modelsTabSource).not.toContain("trpc.claudeCode") + expect(modelsTabSource).not.toContain("trpc.anthropicAccounts") + expect(mcpTabSource).not.toContain("trpc.claude.") + expect(mcpTabSource).not.toContain("trpc.codex.") + expect(mcpIndicatorSource).not.toContain("trpc.claude.") + expect(mcpIndicatorSource).not.toContain("trpc.codex.") + expect(activeChatSource).not.toContain("trpcClient.claude.respondToolApproval") + expect(activeChatSource).not.toContain("new ACPChatTransport") + expect(activeChatSource).not.toContain("new IPCChatTransport") + expect(acpTransportSource).not.toContain("trpcClient.codex.") + expect(ipcTransportSource).not.toContain("trpcClient.claude.") + expect(mcpTabSource).toContain("trpc.opencodex.getAllMcpConfig") + expect(mcpIndicatorSource).toContain("trpc.opencodex.getMcpConfig") + expect(activeChatSource).toContain("trpcClient.opencodex.respondToolApproval") + expect(activeChatSource).toContain("createOpenCodexChatTransport") + expect(acpTransportSource).toContain("trpcClient.opencodex.") + expect(ipcTransportSource).toContain("trpcClient.opencodex.") + expect(modelsTabSource).toContain("trpc.opencodex.getBackendSurface") + expect(modelsTabSource).toContain("OpenCodexBackendEditor") + expect(billingPageSource).toContain("OpenCodexBackendEditor") + expect(backendEditorSource).toContain("window.desktopApi.getBackendHostState()") + expect(backendEditorSource).toContain("window.desktopApi.saveOpenCodexBackendConfig") + expect(backendEditorSource).toContain("Save Backend") + expect(onboardingIndexSource).not.toContain("AnthropicOnboardingPage") + expect(onboardingIndexSource).not.toContain("ApiKeyOnboardingPage") + expect(onboardingIndexSource).not.toContain("CodexOnboardingPage") + expect(sidebarSource).not.toContain("Log out") + expect(sidebarSource).not.toContain("AuthDialog") + expect(sidebarSource).not.toContain("Login") + expect(sidebarSource).not.toContain("remoteTrpc.automations") + expect(sidebarSource).not.toContain("getOpenCodexAutomationsUrl") + expect(sidebarSource).not.toContain("") + expect(sidebarSource).not.toContain("") + expect(sidebarSource).toContain("OpenCodex Backend") + expect(agentsContentSource).not.toContain("AutomationsView") + expect(agentsContentSource).not.toContain("AutomationsDetailView") + expect(agentsContentSource).not.toContain("InboxView") + expect(agentsContentSource).not.toContain("betaAutomationsEnabledAtom") + expect(agentsContentSource).not.toContain("remoteTrpc.automations") + expect(agentsContentSource).toContain("BackendControlView") + expect(betaTabSource).not.toContain("remoteTrpc.agents.getAgentsSubscription") + expect(betaTabSource).not.toContain("Automations & Inbox") + expect(betaTabSource).toContain("Backend Control Center") + expect(rendererAtomsSource).not.toContain("betaAutomationsEnabledAtom") + expect(rendererAtomsSource).not.toContain("automationDetailIdAtom") + expect(rendererAtomsSource).not.toContain("automationTemplateParamsAtom") + expect(rendererAtomsSource).not.toContain("inboxSelectedChatIdAtom") + expect(rendererAtomsSource).not.toContain("agentsInboxSidebarWidthAtom") + expect(rendererAtomsSource).not.toContain("inboxMobileViewModeAtom") + expect(agentsAtomsSource).not.toContain("\"automations\"") + expect(agentsAtomsSource).not.toContain("\"automations-detail\"") + expect(agentsAtomsSource).not.toContain("\"inbox\"") + expect(agentsAtomsSource).toContain("\"backend-control\"") + expect(backendControlSource).toContain("trpc.opencodex.getBackendSurface") + expect(backendControlSource).toContain("trpc.opencodex.getAllMcpConfig") + expect(backendControlSource).toContain("trpc.external.openInFinder") + expect(backendControlSource).toContain("OpenCodexBackendEditor") + expect(loginPageSource).not.toContain("onAuthSuccess") + expect(loginPageSource).not.toContain("startAuthFlow") + expect(loginPageSource).not.toContain("Sign in") + expect(existsSync(codexLoginHookPath)).toBe(false) + expect(existsSync(codexLoginContentPath)).toBe(false) + + expect(profileTabSource).toContain("window.desktopApi.getLocalProfile()") + expect(profileTabSource).toContain("window.desktopApi.updateLocalProfile({ displayName: trimmed })") + expect(layoutSource).toContain("window.desktopApi?.resetLocalWorkspace") + expect(sidebarSource).toContain("Reset local workspace") + expect(loginPageSource).toContain("OpenCodex local backend") + }) +}) diff --git a/tests/wor44-branding.test.mjs b/tests/wor44-branding.test.mjs new file mode 100644 index 000000000..37c8bf357 --- /dev/null +++ b/tests/wor44-branding.test.mjs @@ -0,0 +1,121 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const root = join(__dirname, "..") + +function read(relativePath) { + return readFileSync(join(root, relativePath), "utf8") +} + +test("package metadata uses OpenCodex product branding", () => { + const packageJson = read("package.json") + assert.match(packageJson, /"description": "OpenCodex - UI-first local coding workstation"/) + assert.match(packageJson, /"productName": "OpenCodex"/) + assert.match(packageJson, /"name": "opencodex-desktop"/) +}) + +test("main process product chrome uses OpenCodex language", () => { + const mainSource = read("src/main/index.ts") + const windowsSource = read("src/main/windows/main.ts") + + assert.match(mainSource, /applicationName: "OpenCodex"/) + assert.match(mainSource, /label: "About OpenCodex"/) + assert.match(mainSource, /title>OpenCodex - Authentication1Code - Authentication { + const billing = read("src/renderer/features/onboarding/billing-method-page.tsx") + const anthropic = read("src/renderer/features/onboarding/anthropic-onboarding-page.tsx") + const codex = read("src/renderer/features/agents/components/codex-login-content.tsx") + const html = read("src/renderer/index.html") + const titleBar = read("src/renderer/components/windows-title-bar.tsx") + const sidebar = read("src/renderer/features/sidebar/agents-sidebar.tsx") + + assert.match(billing, /Set Up OpenCodex Backend/) + assert.match(anthropic, /Connect Anthropic Runtime/) + assert.match(codex, /Connect OpenCodex Runtime/) + assert.match(html, /OpenCodex<\/title>/) + assert.match(titleBar, />OpenCodex</) + assert.match(sidebar, /OpenCodex/) + + assert.doesNotMatch(billing, /Connect AI Provider/) + assert.doesNotMatch(billing, /Claude Code/) + assert.doesNotMatch(billing, /Codex Subscription/) + assert.doesNotMatch(anthropic, /Connect Claude Code/) + assert.doesNotMatch(codex, /Connect OpenAI Codex/) +}) + +test("settings and chooser surfaces present compatibility runtimes instead of Claude/Codex product brands", () => { + const models = read("src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx") + const mcp = read("src/renderer/components/dialogs/settings-tabs/agents-mcp-tab.tsx") + const activeChat = read("src/renderer/features/agents/main/active-chat.tsx") + const newChat = read("src/renderer/features/agents/main/new-chat-form.tsx") + const automations = read("src/renderer/features/automations/_components/utils.ts") + const templateCard = read("src/renderer/features/automations/_components/template-card.tsx") + const transport = read("src/renderer/features/agents/lib/ipc-chat-transport.ts") + const automationDetail = read("src/renderer/features/automations/automations-detail-view.tsx") + + assert.match(models, /Anthropic Runtime Accounts/) + assert.match(models, /OpenAI-Compatible Runtime/) + assert.match(mcp, /OpenAI-Compatible/) + assert.match(mcp, /Anthropic-Compatible/) + assert.match(activeChat, /Anthropic-Compatible/) + assert.match(newChat, /OpenAI-Compatible/) + assert.match(automations, /run OpenCodex runtime/) + assert.match(templateCard, /run OpenCodex runtime/) + assert.match(transport, /Anthropic runtime usage limit/) + assert.match(transport, /Anthropic-compatible runtime/) + assert.match(automationDetail, /Repository where the OpenCodex runtime will make changes/) + + assert.doesNotMatch(models, /Codex Account/) + assert.doesNotMatch(models, /Manage your Codex account/) + assert.doesNotMatch(mcp, />OpenAI Codex</) + assert.doesNotMatch(mcp, />Claude Code</) + assert.doesNotMatch(activeChat, /name: "Claude Code"/) + assert.doesNotMatch(newChat, /name: "OpenAI Codex"/) + assert.doesNotMatch(transport, /Claude Code/) + assert.doesNotMatch(automationDetail, /Repository where Claude Code will make changes/) +}) + +test("renderer external links route through OpenCodex link constants instead of scattered legacy URLs", () => { + const links = read("src/renderer/lib/opencodex-links.ts") + const helpPopover = read("src/renderer/features/agents/components/agents-help-popover.tsx") + const sidebar = read("src/renderer/features/sidebar/agents-sidebar.tsx") + const justUpdated = read("src/renderer/lib/hooks/use-just-updated.ts") + const updateBanner = read("src/renderer/components/update-banner.tsx") + const envTypes = read("src/env.d.ts") + + assert.match(links, /VITE_OPENCODEX_COMMUNITY_URL/) + assert.match(links, /VITE_OPENCODEX_CHANGELOG_URL/) + assert.match(links, /VITE_OPENCODEX_AGENTS_CHANGELOG_URL/) + assert.match(links, /VITE_OPENCODEX_CHANGELOG_FEED_URL/) + + assert.match(helpPopover, /OPENCODEX_CHANGELOG_FEED_URL/) + assert.match(helpPopover, /OPENCODEX_COMMUNITY_URL/) + assert.match(helpPopover, /buildOpenCodexAgentsChangelogUrl/) + assert.match(sidebar, /OPENCODEX_COMMUNITY_URL/) + assert.match(justUpdated, /buildOpenCodexChangelogUrl/) + assert.match(updateBanner, /openChangelog\(\)/) + assert.match(envTypes, /VITE_OPENCODEX_COMMUNITY_URL/) + assert.match(envTypes, /VITE_OPENCODEX_CHANGELOG_URL/) + + assert.doesNotMatch(helpPopover, /1code\.dev/) + assert.doesNotMatch(helpPopover, /discord\.gg\/8ektTZGnj4/) + assert.doesNotMatch(sidebar, /discord\.gg\/8ektTZGnj4/) + assert.doesNotMatch(justUpdated, /1code\.dev/) + assert.doesNotMatch(updateBanner, /1code\.dev/) +}) From 2424ec8493fbb974388ba8411dc514b019c02b92 Mon Sep 17 00:00:00 2001 From: Administrator <3625568490@qq.com> Date: Fri, 17 Apr 2026 21:42:55 +0800 Subject: [PATCH 2/2] Add canonical OpenCodex backend route model --- src/main/lib/opencodex/backend-config.test.ts | 94 ++++++ src/main/lib/opencodex/backend-config.ts | 48 +-- src/main/lib/opencodex/backend-host.test.ts | 40 ++- src/main/lib/opencodex/backend-host.ts | 43 ++- src/main/lib/trpc/routers/opencodex.ts | 66 ++-- src/preload/index.d.ts | 8 +- src/preload/index.ts | 8 +- src/renderer/App.tsx | 2 +- .../settings-tabs/agents-models-tab.tsx | 7 +- .../features/agents/main/new-chat-form.tsx | 21 +- .../opencodex-backend-config.test.ts | 59 +++- .../onboarding/opencodex-backend-config.ts | 135 +++++--- .../onboarding/opencodex-backend-editor.tsx | 280 +++++++++++----- .../opencodex/backend-control-view.tsx | 6 +- src/renderer/lib/atoms/index.ts | 53 +-- src/shared/opencodex-backend-route.test.ts | 128 +++++++ src/shared/opencodex-backend-route.ts | 314 ++++++++++++++++++ 17 files changed, 1022 insertions(+), 290 deletions(-) create mode 100644 src/main/lib/opencodex/backend-config.test.ts create mode 100644 src/shared/opencodex-backend-route.test.ts create mode 100644 src/shared/opencodex-backend-route.ts diff --git a/src/main/lib/opencodex/backend-config.test.ts b/src/main/lib/opencodex/backend-config.test.ts new file mode 100644 index 000000000..289028391 --- /dev/null +++ b/src/main/lib/opencodex/backend-config.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { + readOpenCodexBackendConfig, + saveOpenCodexBackendConfig, +} from "./backend-config" + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +describe("OpenCodex backend config persistence", () => { + test("round-trips the canonical route union and trims api-backed fields", () => { + const userDataPath = mkdtempSync(join(process.cwd(), "tmp-opencodex-backend-config-")) + tempDirs.push(userDataPath) + + const saved = saveOpenCodexBackendConfig({ + userDataPath, + config: { + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "anthropic-compatible", + baseUrl: " https://proxy.example.com ", + model: " claude-sonnet-4-6 ", + apiKey: " sk-ant-123456789012345678901 ", + }, + }) + + expect(saved).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "anthropic-compatible", + baseUrl: "https://proxy.example.com", + model: "claude-sonnet-4-6", + apiKey: "sk-ant-123456789012345678901", + }) + expect(readOpenCodexBackendConfig({ userDataPath })).toEqual(saved) + }) + + test("migrates the legacy narrow config file into the canonical openai-compatible api route", () => { + const userDataPath = mkdtempSync(join(process.cwd(), "tmp-opencodex-backend-config-")) + tempDirs.push(userDataPath) + + const configPath = join(userDataPath, "opencodex", "state", "backend-config.json") + rmSync(join(userDataPath, "opencodex"), { recursive: true, force: true }) + mkdirSync(join(userDataPath, "opencodex", "state"), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + providerFamily: "openai-compatible", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-legacy-openai", + }, null, 2), + "utf8", + ) + + expect(readOpenCodexBackendConfig({ userDataPath })).toEqual({ + kind: "openai-compatible-api", + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-legacy-openai", + }) + }) + + test("rejects invalid backend route payloads instead of accepting degraded config", () => { + const userDataPath = mkdtempSync(join(process.cwd(), "tmp-opencodex-backend-config-")) + tempDirs.push(userDataPath) + + const configPath = join(userDataPath, "opencodex", "state", "backend-config.json") + rmSync(join(userDataPath, "opencodex"), { recursive: true, force: true }) + mkdirSync(join(userDataPath, "opencodex", "state"), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify({ + kind: "anthropic-compatible-api", + authSource: "api-key", + baseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-6", + apiKey: "sk-invalid", + }, null, 2), + "utf8", + ) + + expect(readOpenCodexBackendConfig({ userDataPath })).toBeNull() + }) +}) \ No newline at end of file diff --git a/src/main/lib/opencodex/backend-config.ts b/src/main/lib/opencodex/backend-config.ts index 6dd44dc07..394c8f3f5 100644 --- a/src/main/lib/opencodex/backend-config.ts +++ b/src/main/lib/opencodex/backend-config.ts @@ -1,30 +1,24 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" +import { + parseOpenCodexBackendRoute, + type OpenCodexBackendRoute, +} from "../../../shared/opencodex-backend-route" import { resolveOpenCodexDataPaths } from "./preflight" -export type OpenCodexBackendProviderFamily = - | "openai-compatible" - | "anthropic-compatible" - | "custom" - -export interface OpenCodexBackendConfigRecord { - providerFamily: OpenCodexBackendProviderFamily - baseUrl: string - model: string - apiKey: string -} +export type OpenCodexBackendConfigRecord = OpenCodexBackendRoute function getBackendConfigPath(userDataPath: string): string { return join(resolveOpenCodexDataPaths(userDataPath).stateDir, "backend-config.json") } function normalizeConfig(config: OpenCodexBackendConfigRecord): OpenCodexBackendConfigRecord { - return { - providerFamily: config.providerFamily, - baseUrl: config.baseUrl.trim(), - model: config.model.trim(), - apiKey: config.apiKey.trim(), + const normalized = parseOpenCodexBackendRoute(config) + if (!normalized) { + throw new Error("OpenCodex backend config is invalid") } + + return normalized } export function readOpenCodexBackendConfig({ @@ -37,24 +31,8 @@ export function readOpenCodexBackendConfig({ return null } - const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Partial<OpenCodexBackendConfigRecord> - if ( - parsed.providerFamily !== "openai-compatible" && - parsed.providerFamily !== "anthropic-compatible" && - parsed.providerFamily !== "custom" - ) { - return null - } - - if ( - typeof parsed.baseUrl !== "string" || - typeof parsed.model !== "string" || - typeof parsed.apiKey !== "string" - ) { - return null - } - - return normalizeConfig(parsed as OpenCodexBackendConfigRecord) + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown + return parseOpenCodexBackendRoute(parsed) } export function saveOpenCodexBackendConfig({ @@ -78,4 +56,4 @@ export function resetOpenCodexBackendConfig({ userDataPath: string }): void { rmSync(getBackendConfigPath(userDataPath), { force: true }) -} +} \ No newline at end of file diff --git a/src/main/lib/opencodex/backend-host.test.ts b/src/main/lib/opencodex/backend-host.test.ts index b7e70af5a..4db20e1fc 100644 --- a/src/main/lib/opencodex/backend-host.test.ts +++ b/src/main/lib/opencodex/backend-host.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { buildOpenCodexBackendHostLaunchSpec, resolveOpenCodexBackendHostPaths, + restartOpenCodexBackendHost, } from "./backend-host" import { saveOpenCodexBackendConfig } from "./backend-config" @@ -28,14 +29,15 @@ describe("OpenCodex backend host supervisor", () => { expect(paths.logsDir.endsWith(join("opencodex", "app-server-home", "openharness-logs"))).toBe(true) }) - test("builds a launch spec from the saved backend config and prefers utf-8 python mode", () => { + test("builds a launch spec from the saved api-backed route and prefers utf-8 python mode", () => { const userDataPath = mkdtempSync(join(process.cwd(), "tmp-opencodex-backend-host-")) tempDirs.push(userDataPath) saveOpenCodexBackendConfig({ userDataPath, config: { - providerFamily: "openai-compatible", + kind: "openai-compatible-api", + authSource: "api-key", baseUrl: "https://api.openai.com/v1", model: "gpt-5.2", apiKey: "sk-opencodex-test", @@ -58,6 +60,36 @@ describe("OpenCodex backend host supervisor", () => { expect(spec.inlineScript).toContain("\"https://api.openai.com/v1\"") expect(spec.inlineScript).toContain("\"sk-opencodex-test\"") expect(spec.env.PYTHONUTF8).toBe("1") - expect(spec.env.OPENHARNESS_CONFIG_DIR.includes("openharness-config")).toBe(true) + expect(spec.env.OPENHARNESS_CONFIG_DIR?.includes("openharness-config")).toBe(true) }) -}) + + test("does not try to materialize the backend host for subscription bridge routes", async () => { + const userDataPath = mkdtempSync(join(process.cwd(), "tmp-opencodex-backend-host-")) + tempDirs.push(userDataPath) + + saveOpenCodexBackendConfig({ + userDataPath, + config: { + kind: "codex-subscription", + authSource: "codex-local-auth", + }, + }) + + expect(() => + buildOpenCodexBackendHostLaunchSpec({ + appRoot: process.cwd(), + userDataPath, + workspacePath: process.cwd(), + }), + ).toThrow("route kind codex-subscription") + + const state = await restartOpenCodexBackendHost({ + appRoot: process.cwd(), + userDataPath, + workspacePath: process.cwd(), + }) + + expect(state.status).toBe("stopped") + expect(state.lastEventType).toBe("codex-subscription") + }) +}) \ No newline at end of file diff --git a/src/main/lib/opencodex/backend-host.ts b/src/main/lib/opencodex/backend-host.ts index 36ab9a955..56b558c7b 100644 --- a/src/main/lib/opencodex/backend-host.ts +++ b/src/main/lib/opencodex/backend-host.ts @@ -2,6 +2,11 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process" import { existsSync, mkdirSync } from "node:fs" import { resolve, join } from "node:path" import { createInterface } from "node:readline" +import { + getOpenCodexBackendProviderFamily, + openCodexBackendRouteRequiresHost, + type OpenCodexBackendRoute, +} from "../../../shared/opencodex-backend-route" import { readOpenCodexBackendConfig, type OpenCodexBackendConfigRecord, @@ -37,22 +42,26 @@ function jsonLiteral(value: unknown): string { function buildBackendOnlyScript({ openharnessSrcPath, - config, + route, workspacePath, }: { openharnessSrcPath: string - config: OpenCodexBackendConfigRecord + route: OpenCodexBackendRoute workspacePath: string }): string { - const apiFormat = - config.providerFamily === "anthropic-compatible" ? "anthropic" : "openai" + const providerFamily = getOpenCodexBackendProviderFamily(route) + if (!providerFamily || route.kind === "codex-subscription" || route.kind === "claude-subscription") { + throw new Error(`OpenCodex backend host launch is not materialized for route kind ${route.kind}`) + } + + const apiFormat = providerFamily === "anthropic-compatible" ? "anthropic" : "openai" return [ "import asyncio", "import sys", `sys.path.insert(0, ${jsonLiteral(openharnessSrcPath)})`, "from openharness.ui.app import run_repl", - `asyncio.run(run_repl(backend_only=True, cwd=${jsonLiteral(workspacePath)}, model=${jsonLiteral(config.model)}, base_url=${jsonLiteral(config.baseUrl)}, api_key=${jsonLiteral(config.apiKey)}, api_format=${jsonLiteral(apiFormat)}, permission_mode='default'))`, + `asyncio.run(run_repl(backend_only=True, cwd=${jsonLiteral(workspacePath)}, model=${jsonLiteral(route.model)}, base_url=${jsonLiteral(route.baseUrl)}, api_key=${jsonLiteral(route.apiKey)}, api_format=${jsonLiteral(apiFormat)}, permission_mode='default'))`, ].join("\n") } @@ -85,6 +94,9 @@ export function buildOpenCodexBackendHostLaunchSpec({ if (!config) { throw new Error("OpenCodex backend host cannot start without a saved backend config") } + if (!openCodexBackendRouteRequiresHost(config)) { + throw new Error(`OpenCodex backend host launch is not materialized for route kind ${config.kind}`) + } const openharnessRoot = resolve(appRoot, "..", "openharness") const openharnessSrcPath = join(openharnessRoot, "src") @@ -109,7 +121,7 @@ export function buildOpenCodexBackendHostLaunchSpec({ (process.platform === "win32" ? "py" : "python3") const inlineScript = buildBackendOnlyScript({ openharnessSrcPath, - config, + route: config, workspacePath: resolve(workspacePath), }) const args = @@ -157,6 +169,23 @@ class OpenCodexBackendHostSupervisor { userDataPath: string workspacePath: string }): Promise<OpenCodexBackendHostState> { + const route = readOpenCodexBackendConfig({ userDataPath }) + if (!route) { + return this.getState() + } + + if (!openCodexBackendRouteRequiresHost(route)) { + await this.stop() + this.state = { + status: "stopped", + pid: null, + startedAt: null, + lastError: null, + lastEventType: route.kind, + } + return this.getState() + } + if (this.child && this.state.status === "ready") { return this.getState() } @@ -328,4 +357,4 @@ export async function restartOpenCodexBackendHost({ export async function stopOpenCodexBackendHost(): Promise<void> { await getSupervisor().stop() -} +} \ No newline at end of file diff --git a/src/main/lib/trpc/routers/opencodex.ts b/src/main/lib/trpc/routers/opencodex.ts index 33f33f6dc..84236b500 100644 --- a/src/main/lib/trpc/routers/opencodex.ts +++ b/src/main/lib/trpc/routers/opencodex.ts @@ -11,8 +11,13 @@ import { import { publicProcedure, router, type Context } from "../index" import { claudeRouter } from "./claude" import { codexRouter } from "./codex" +import { + getOpenCodexBackendProviderFamily, + getOpenCodexBackendRouteTitle, + getOpenCodexBackendRuntimeKind, +} from "../../../../shared/opencodex-backend-route" -type OpenCodexBackendRouteKind = "codex" | "claude" +type OpenCodexBackendRuntimeKind = "codex" | "claude" function getBackendConfig(): OpenCodexBackendConfigRecord | null { return readOpenCodexBackendConfig({ @@ -20,31 +25,12 @@ function getBackendConfig(): OpenCodexBackendConfigRecord | null { }) } -function getProviderTitle( - providerFamily: OpenCodexBackendConfigRecord["providerFamily"], -): string { - switch (providerFamily) { - case "anthropic-compatible": - return "Anthropic-Compatible" - case "custom": - return "Custom Endpoint" - default: - return "OpenAI-Compatible" - } -} - -function resolveRouteKind( - providerFamily: OpenCodexBackendConfigRecord["providerFamily"], -): OpenCodexBackendRouteKind { - return providerFamily === "openai-compatible" ? "codex" : "claude" -} - -function getCapabilities(routeKind: OpenCodexBackendRouteKind) { - return routeKind === "codex" +function getCapabilities(config: OpenCodexBackendConfigRecord) { + return getOpenCodexBackendRuntimeKind(config) === "codex" ? { projectScope: false, toggleServer: false, - logout: true, + logout: config.kind === "codex-subscription", } : { projectScope: true, @@ -66,13 +52,14 @@ function getActiveBackendRoute(ctx: Context) { return null } - const routeKind = resolveRouteKind(config.providerFamily) + const runtimeKind = getOpenCodexBackendRuntimeKind(config) return { config, - routeKind, - title: getProviderTitle(config.providerFamily), - capabilities: getCapabilities(routeKind), + routeKind: runtimeKind as OpenCodexBackendRuntimeKind, + providerFamily: getOpenCodexBackendProviderFamily(config), + title: getOpenCodexBackendRouteTitle(config), + capabilities: getCapabilities(config), callers: getCallers(ctx), } } @@ -83,7 +70,7 @@ async function getRuntimeIntegration(ctx: Context) { return null } - if (route.routeKind === "codex") { + if (route.config.kind === "codex-subscription") { const integration = await route.callers.codex.getIntegration() return { state: integration.state, @@ -94,8 +81,21 @@ async function getRuntimeIntegration(ctx: Context) { } } + if (route.config.kind === "openai-compatible-api") { + return { + state: "configured_api_key" as const, + isConnected: true, + rawOutput: null, + exitCode: 0, + canDisconnect: false, + } + } + return { - state: "configured" as const, + state: + route.config.kind === "claude-subscription" + ? ("configured_subscription" as const) + : ("configured_api_key" as const), isConnected: true, rawOutput: null, exitCode: 0, @@ -158,7 +158,7 @@ export const openCodexRouter = router({ ? null : { routeKind: route.routeKind, - providerFamily: route.config.providerFamily, + providerFamily: route.providerFamily, title: route.title, capabilities: route.capabilities, }, @@ -188,7 +188,7 @@ export const openCodexRouter = router({ backendHost: getOpenCodexBackendHostState(), runtime: { routeKind: route.routeKind, - providerFamily: route.config.providerFamily, + providerFamily: route.providerFamily, title: route.title, capabilities: route.capabilities, }, @@ -223,7 +223,7 @@ export const openCodexRouter = router({ backendHost: getOpenCodexBackendHostState(), runtime: { routeKind: route.routeKind, - providerFamily: route.config.providerFamily, + providerFamily: route.providerFamily, title: route.title, capabilities: route.capabilities, }, @@ -249,7 +249,7 @@ export const openCodexRouter = router({ backendHost: getOpenCodexBackendHostState(), runtime: { routeKind: route.routeKind, - providerFamily: route.config.providerFamily, + providerFamily: route.providerFamily, title: route.title, capabilities: route.capabilities, }, diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ae8a49451..90d9a40fe 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,3 +1,4 @@ +import type { OpenCodexBackendRoute } from "../shared/opencodex-backend-route" export type OpenCodexStartupState = | { status: "ready" } | { @@ -30,12 +31,7 @@ export interface OpenCodexLocalProfile { identityLabel: string } -export interface OpenCodexBackendConfigInput { - providerFamily: "openai-compatible" | "anthropic-compatible" | "custom" - baseUrl: string - model: string - apiKey: string -} +export type OpenCodexBackendConfigInput = OpenCodexBackendRoute export interface OpenCodexBackendHostState { status: "stopped" | "starting" | "ready" | "error" diff --git a/src/preload/index.ts b/src/preload/index.ts index 3b921e084..86ffede7c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" +import type { OpenCodexBackendRoute } from "../shared/opencodex-backend-route" import { exposeElectronTRPC } from "trpc-electron/main" // Only initialize Sentry in production to avoid IPC errors in dev mode @@ -244,12 +245,7 @@ export interface OpenCodexLocalProfile { identityLabel: string } -export interface OpenCodexBackendConfigInput { - providerFamily: "openai-compatible" | "anthropic-compatible" | "custom" - baseUrl: string - model: string - apiKey: string -} +export type OpenCodexBackendConfigInput = OpenCodexBackendRoute export interface OpenCodexBackendHostState { status: "stopped" | "starting" | "ready" | "error" diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c75114e36..31c980641 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -106,7 +106,7 @@ function AppContent() { normalizeOpenCodexBackendConfig(backendConfig), ), hasValidatedProject: Boolean(validatedProject), - isProjectsLoading, + isProjectsLoading: isLoadingProjects, }), [backendConfig, validatedProject, isLoadingProjects], ) diff --git a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx index fece4f632..90d6edecf 100644 --- a/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx +++ b/src/renderer/components/dialogs/settings-tabs/agents-models-tab.tsx @@ -171,7 +171,7 @@ export function AgentsModelsTab() { try { const result = await disconnectRuntimeMutation.mutateAsync() if (!result.success) { - throw new Error(result.error || "Failed to disconnect runtime") + throw new Error("error" in result ? result.error : "Failed to disconnect runtime") } await trpcUtils.opencodex.getBackendSurface.invalidate() toast.success("Runtime disconnected") @@ -207,8 +207,9 @@ export function AgentsModelsTab() { : isCodexRoute ? runtimeIntegration?.state === "connected_chatgpt" ? "Connected through an inherited local session" - : runtimeIntegration?.state === "connected_api_key" - ? "Connected through the configured API route" + : runtimeIntegration?.state === "connected_api_key" || + runtimeIntegration?.state === "configured_api_key" + ? "Configured through the saved API route" : runtimeIntegration?.state === "not_logged_in" ? "Waiting for a valid backend API key" : "Runtime status unavailable" diff --git a/src/renderer/features/agents/main/new-chat-form.tsx b/src/renderer/features/agents/main/new-chat-form.tsx index 88562a7f5..c17a75329 100644 --- a/src/renderer/features/agents/main/new-chat-form.tsx +++ b/src/renderer/features/agents/main/new-chat-form.tsx @@ -43,13 +43,13 @@ import { selectedProjectAtom, getNextMode, type AgentMode, + type SavedRepo, } from "../atoms" import { defaultAgentModeAtom } from "../../../lib/atoms" import { ProjectSelector } from "../components/project-selector" import { WorkModeSelector } from "../components/work-mode-selector" // import { selectedTeamIdAtom } from "@/lib/atoms/team" import { atom } from "jotai" -const selectedTeamIdAtom = atom<string | null>(null) import { agentsSettingsDialogOpenAtom, agentsSettingsDialogActiveTabAtom, @@ -72,6 +72,7 @@ import { // Desktop uses real tRPC import { toast } from "sonner" import { trpc } from "../../../lib/trpc" + import { AgentsSlashCommand, COMMAND_PROMPTS, @@ -126,6 +127,12 @@ import { type CodexThinkingLevel, } from "../lib/models" import { getOpenCodexProviderLabel } from "../lib/opencodex-runtime" + +type DesktopRepo = NonNullable<SavedRepo> & { + pushed_at?: string | null +} + +const selectedTeamIdAtom = atom<string | null>(null) // import type { PlanType } from "@/lib/config/subscription-plans" type PlanType = string @@ -257,10 +264,16 @@ export function NewChatForm({ [backendConfig], ) const isClaudeConnected = - Boolean(normalizedBackendConfig) || + normalizedBackendConfig?.kind === "claude-subscription" || + normalizedBackendConfig?.kind === "anthropic-compatible-api" || + normalizedBackendConfig?.kind === "custom-endpoint" || anthropicOnboardingCompleted || apiKeyOnboardingCompleted || hasCustomClaudeConfig + const isCodexConnected = + normalizedBackendConfig?.kind === "codex-subscription" || + normalizedBackendConfig?.kind === "openai-compatible-api" || + codexOnboardingCompleted const setSettingsDialogOpen = useSetAtom(agentsSettingsDialogOpenAtom) const setSettingsActiveTab = useSetAtom(agentsSettingsDialogActiveTabAtom) const setJustCreatedIds = useSetAtom(justCreatedIdsAtom) @@ -722,7 +735,7 @@ export function NewChatForm({ // Fetch repos from team // Desktop: no remote repos, we use local projects - const reposData = { repositories: [] } + const reposData: { repositories: DesktopRepo[] } = { repositories: [] } const isLoadingRepos = false // Memoize repos arrays to prevent useEffect from running on every keystroke @@ -1937,7 +1950,7 @@ export function NewChatForm({ }, selectedThinking: selectedCodexThinking, onSelectThinking: setLastSelectedCodexThinking, - isConnected: codexOnboardingCompleted, + isConnected: isCodexConnected, }} /> </div> diff --git a/src/renderer/features/onboarding/opencodex-backend-config.test.ts b/src/renderer/features/onboarding/opencodex-backend-config.test.ts index 491beb795..c5de61cc9 100644 --- a/src/renderer/features/onboarding/opencodex-backend-config.test.ts +++ b/src/renderer/features/onboarding/opencodex-backend-config.test.ts @@ -5,50 +5,75 @@ import { } from "./opencodex-backend-config" describe("OpenCodex backend config bridge", () => { - test("maps OpenAI-compatible backend config onto the codex runtime seam", () => { + test("maps Codex subscription routes onto the codex runtime seam without an api key", () => { const bridge = bridgeOpenCodexBackendConfig({ - providerFamily: "openai-compatible", - baseUrl: "https://api.openai.com/v1", - model: "gpt-5.2", - apiKey: "sk-openai-test", + kind: "codex-subscription", + authSource: "codex-local-auth", }) - expect(bridge.billingMethod).toBe("codex-api-key") + expect(bridge.billingMethod).toBe("codex-subscription") expect(bridge.codexOnboardingCompleted).toBe(true) - expect(bridge.codexApiKey).toBe("sk-openai-test") + expect(bridge.codexOnboardingAuthMethod).toBe("chatgpt") + expect(bridge.codexApiKey).toBe("") expect(bridge.lastSelectedAgentId).toBe("codex") }) - test("maps Anthropic-compatible backend config onto the custom runtime seam", () => { + test("maps anthropic-compatible api routes onto the claude bridge state", () => { const bridge = bridgeOpenCodexBackendConfig({ - providerFamily: "anthropic-compatible", + kind: "anthropic-compatible-api", + authSource: "api-key", baseUrl: "https://api.anthropic.com", model: "claude-sonnet-4-6", - apiKey: "sk-ant-test", + apiKey: "sk-ant-123456789012345678901", }) - expect(bridge.billingMethod).toBe("custom-model") + expect(bridge.billingMethod).toBe("api-key") expect(bridge.apiKeyOnboardingCompleted).toBe(true) expect(bridge.customClaudeConfig).toEqual({ model: "claude-sonnet-4-6", - token: "sk-ant-test", + token: "sk-ant-123456789012345678901", baseUrl: "https://api.anthropic.com", }) expect(bridge.lastSelectedAgentId).toBe("claude-code") }) - test("provides stable provider templates for backend setup", () => { - expect(getOpenCodexBackendTemplate("openai-compatible")).toEqual({ + test("maps custom endpoint routes onto the custom-model bridge state", () => { + const bridge = bridgeOpenCodexBackendConfig({ + kind: "custom-endpoint", + authSource: "api-key", providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "sk-custom-endpoint", + }) + + expect(bridge.billingMethod).toBe("custom-model") + expect(bridge.customClaudeConfig).toEqual({ + model: "opencodex-default", + token: "sk-custom-endpoint", + baseUrl: "http://127.0.0.1:8000/v1", + }) + }) + + test("provides stable route templates for onboarding and settings", () => { + expect(getOpenCodexBackendTemplate("openai-compatible-api")).toEqual({ + kind: "openai-compatible-api", + authSource: "api-key", baseUrl: "https://api.openai.com/v1", model: "gpt-5.2", apiKey: "", }) - expect(getOpenCodexBackendTemplate("custom")).toEqual({ - providerFamily: "custom", + expect(getOpenCodexBackendTemplate("codex-subscription")).toEqual({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }) + expect(getOpenCodexBackendTemplate("custom-endpoint")).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", baseUrl: "http://127.0.0.1:8000/v1", model: "opencodex-default", apiKey: "", }) }) -}) +}) \ No newline at end of file diff --git a/src/renderer/features/onboarding/opencodex-backend-config.ts b/src/renderer/features/onboarding/opencodex-backend-config.ts index 66766f7ac..7db2f861e 100644 --- a/src/renderer/features/onboarding/opencodex-backend-config.ts +++ b/src/renderer/features/onboarding/opencodex-backend-config.ts @@ -2,9 +2,15 @@ import type { BillingMethod, CodexOnboardingAuthMethod, CustomClaudeConfig, - OpenCodexBackendConfig, - OpenCodexBackendProviderFamily, } from "../../lib/atoms" +import { + getOpenCodexBackendRouteTemplate, + type OpenCodexBackendRoute, + type OpenCodexBackendRouteKind, +} from "../../../shared/opencodex-backend-route" + +export type OpenCodexBackendConfig = OpenCodexBackendRoute +export type OpenCodexBackendProviderFamily = OpenCodexBackendRouteKind export type OpenCodexBackendRuntimeBridge = { billingMethod: BillingMethod @@ -17,63 +23,84 @@ export type OpenCodexBackendRuntimeBridge = { } export function getOpenCodexBackendTemplate( - providerFamily: OpenCodexBackendProviderFamily, + kind: OpenCodexBackendRouteKind, ): OpenCodexBackendConfig { - switch (providerFamily) { - case "anthropic-compatible": + return getOpenCodexBackendRouteTemplate(kind) +} + +export function bridgeOpenCodexBackendConfig( + config: OpenCodexBackendConfig, +): OpenCodexBackendRuntimeBridge { + switch (config.kind) { + case "codex-subscription": return { - providerFamily, - baseUrl: "https://api.anthropic.com", - model: "claude-sonnet-4-6", - apiKey: "", + billingMethod: "codex-subscription", + customClaudeConfig: { + model: "", + token: "", + baseUrl: "", + }, + apiKeyOnboardingCompleted: false, + codexOnboardingCompleted: true, + codexOnboardingAuthMethod: "chatgpt", + codexApiKey: "", + lastSelectedAgentId: "codex", } - case "custom": + case "openai-compatible-api": return { - providerFamily, - baseUrl: "http://127.0.0.1:8000/v1", - model: "opencodex-default", - apiKey: "", + billingMethod: "codex-api-key", + customClaudeConfig: { + model: "", + token: "", + baseUrl: "", + }, + apiKeyOnboardingCompleted: false, + codexOnboardingCompleted: true, + codexOnboardingAuthMethod: "api_key", + codexApiKey: config.apiKey, + lastSelectedAgentId: "codex", } - default: + case "anthropic-compatible-api": return { - providerFamily, - baseUrl: "https://api.openai.com/v1", - model: "gpt-5.2", - apiKey: "", + billingMethod: "api-key", + customClaudeConfig: { + model: config.model, + token: config.apiKey, + baseUrl: config.baseUrl, + }, + apiKeyOnboardingCompleted: true, + codexOnboardingCompleted: false, + codexOnboardingAuthMethod: "api_key", + codexApiKey: "", + lastSelectedAgentId: "claude-code", + } + case "claude-subscription": + return { + billingMethod: "claude-subscription", + customClaudeConfig: { + model: "", + token: "", + baseUrl: "", + }, + apiKeyOnboardingCompleted: false, + codexOnboardingCompleted: false, + codexOnboardingAuthMethod: "api_key", + codexApiKey: "", + lastSelectedAgentId: "claude-code", + } + case "custom-endpoint": + return { + billingMethod: "custom-model", + customClaudeConfig: { + model: config.model, + token: config.apiKey, + baseUrl: config.baseUrl, + }, + apiKeyOnboardingCompleted: true, + codexOnboardingCompleted: false, + codexOnboardingAuthMethod: "api_key", + codexApiKey: "", + lastSelectedAgentId: "claude-code", } } -} - -export function bridgeOpenCodexBackendConfig( - config: OpenCodexBackendConfig, -): OpenCodexBackendRuntimeBridge { - if (config.providerFamily === "openai-compatible") { - return { - billingMethod: "codex-api-key", - customClaudeConfig: { - model: "", - token: "", - baseUrl: "", - }, - apiKeyOnboardingCompleted: false, - codexOnboardingCompleted: true, - codexOnboardingAuthMethod: "api_key", - codexApiKey: config.apiKey, - lastSelectedAgentId: "codex", - } - } - - return { - billingMethod: "custom-model", - customClaudeConfig: { - model: config.model, - token: config.apiKey, - baseUrl: config.baseUrl, - }, - apiKeyOnboardingCompleted: true, - codexOnboardingCompleted: false, - codexOnboardingAuthMethod: "api_key", - codexApiKey: "", - lastSelectedAgentId: "claude-code", - } -} +} \ No newline at end of file diff --git a/src/renderer/features/onboarding/opencodex-backend-editor.tsx b/src/renderer/features/onboarding/opencodex-backend-editor.tsx index 1f4d8a089..7029a6bae 100644 --- a/src/renderer/features/onboarding/opencodex-backend-editor.tsx +++ b/src/renderer/features/onboarding/opencodex-backend-editor.tsx @@ -7,30 +7,59 @@ import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { Label } from "../../components/ui/label" import { Logo } from "../../components/ui/logo" -import type { - OpenCodexBackendConfig, - OpenCodexBackendProviderFamily, -} from "../../lib/atoms" +import type { OpenCodexBackendConfig } from "../../lib/atoms" import { normalizeOpenCodexBackendConfig } from "../../lib/atoms" import { cn } from "../../lib/utils" +import { + getOpenCodexBackendRouteTitle, + openCodexBackendRouteRequiresHost, + type OpenCodexBackendProviderFamily, + type OpenCodexBackendRouteKind, +} from "../../../shared/opencodex-backend-route" import { getOpenCodexBackendTemplate } from "./opencodex-backend-config" -const PROVIDER_OPTIONS = [ +const ROUTE_OPTIONS = [ { - id: "openai-compatible", - title: "OpenAI-Compatible", + id: "codex-subscription", + title: "Codex Subscription", + description: "Reuse the machine's local Codex-authenticated session bridge.", + }, + { + id: "claude-subscription", + title: "Claude Subscription", + description: "Reuse the machine's local Claude-authenticated session bridge.", + }, + { + id: "openai-compatible-api", + title: "OpenAI-Compatible API", description: "Route OpenCodex through an OpenAI-compatible API target.", }, { - id: "anthropic-compatible", - title: "Anthropic-Compatible", + id: "anthropic-compatible-api", + title: "Anthropic-Compatible API", description: "Route OpenCodex through an Anthropic-compatible API target.", }, { - id: "custom", + id: "custom-endpoint", title: "Custom Endpoint", - description: - "Route OpenCodex through a custom local or remote OpenAI-style endpoint.", + description: "Use a custom local or remote endpoint with an explicit provider family.", + }, +] as const satisfies ReadonlyArray<{ + id: OpenCodexBackendRouteKind + title: string + description: string +}> + +const CUSTOM_PROVIDER_OPTIONS = [ + { + id: "openai-compatible", + title: "OpenAI-Compatible", + description: "Custom endpoint with OpenAI-style request semantics.", + }, + { + id: "anthropic-compatible", + title: "Anthropic-Compatible", + description: "Custom endpoint with Anthropic-style request semantics.", }, ] as const satisfies ReadonlyArray<{ id: OpenCodexBackendProviderFamily @@ -40,6 +69,10 @@ const PROVIDER_OPTIONS = [ type OpenCodexBackendEditorVariant = "onboarding" | "settings" +type OpenCodexBackendHostState = Awaited< + ReturnType<Window["desktopApi"]["getBackendHostState"]> +> + type OpenCodexBackendEditorProps = { variant: OpenCodexBackendEditorVariant initialConfig: OpenCodexBackendConfig @@ -97,6 +130,13 @@ export function OpenCodexBackendEditor({ [draft], ) + const routeNeedsEndpointFields = + draft.kind === "openai-compatible-api" || + draft.kind === "anthropic-compatible-api" || + draft.kind === "custom-endpoint" + + const routeRequiresHost = openCodexBackendRouteRequiresHost(draft) + const refreshHostState = async () => { setIsRefreshingHostState(true) try { @@ -120,29 +160,63 @@ export function OpenCodexBackendEditor({ }, []) const updateField = ( - field: keyof Pick<OpenCodexBackendConfig, "baseUrl" | "model" | "apiKey">, + field: "baseUrl" | "model" | "apiKey", value: string, ) => { setError(null) - setDraft((current) => ({ - ...current, - [field]: value, - })) + setDraft((current: OpenCodexBackendConfig) => { + if (!(field in current)) { + return current + } + return { + ...current, + [field]: value, + } as OpenCodexBackendConfig + }) } - const updateProvider = (providerFamily: OpenCodexBackendProviderFamily) => { + const updateRouteKind = (kind: OpenCodexBackendRouteKind) => { setError(null) - setDraft((current) => ({ - ...getOpenCodexBackendTemplate(providerFamily), - apiKey: - current.providerFamily === providerFamily ? current.apiKey : "", - })) + setDraft((current: OpenCodexBackendConfig) => { + const next = getOpenCodexBackendTemplate(kind) + if ("apiKey" in next && "apiKey" in current && current.kind === kind) { + return { + ...next, + apiKey: current.apiKey, + } + } + if (kind === "custom-endpoint" && current.kind === "custom-endpoint") { + return { + ...next, + providerFamily: current.providerFamily, + apiKey: current.apiKey, + } + } + return next + }) + } + + const updateCustomProviderFamily = ( + providerFamily: OpenCodexBackendProviderFamily, + ) => { + setError(null) + setDraft((current: OpenCodexBackendConfig) => { + if (current.kind !== "custom-endpoint") { + return current + } + return { + ...current, + providerFamily, + } + }) } const applyBackendConfig = async () => { if (!normalizedConfig) { setError( - "Complete the backend base URL, model, and credential before applying the local backend route.", + routeNeedsEndpointFields + ? "Complete the route details and credential before applying this backend route." + : "Select a valid backend route before applying it.", ) return } @@ -155,7 +229,10 @@ export function OpenCodexBackendEditor({ const nextHostState = await window.desktopApi.restartBackendHost() setHostState(nextHostState) - if (nextHostState.status !== "ready") { + if ( + openCodexBackendRouteRequiresHost(normalizedConfig) && + nextHostState.status !== "ready" + ) { setError( nextHostState.lastError || "OpenCodex could not start the local backend host.", @@ -183,7 +260,7 @@ export function OpenCodexBackendEditor({ const nextHostState = await window.desktopApi.restartBackendHost() setHostState(nextHostState) - if (nextHostState.status !== "ready") { + if (routeRequiresHost && nextHostState.status !== "ready") { setError( nextHostState.lastError || "OpenCodex could not restart the local backend host.", @@ -211,6 +288,14 @@ export function OpenCodexBackendEditor({ ? "space-y-5 rounded-2xl border border-border bg-card p-6 shadow-sm" : "space-y-5 rounded-2xl border border-border bg-card p-5 shadow-sm" + const credentialPlaceholder = + draft.kind === "anthropic-compatible-api" || + (draft.kind === "custom-endpoint" && draft.providerFamily === "anthropic-compatible") + ? "sk-ant-..." + : "sk-..." + + const routeSummary = getOpenCodexBackendRouteTitle(draft) + return ( <div className={cardClassName}> {variant === "onboarding" && ( @@ -227,8 +312,8 @@ export function OpenCodexBackendEditor({ <h1 className="text-xl font-semibold tracking-tight"> Configure OpenCodex Backend </h1> - <p className="mx-auto max-w-[460px] text-sm leading-6 text-muted-foreground"> - Configure the local backend once, keep the APP-Server hidden behind + <p className="mx-auto max-w-[500px] text-sm leading-6 text-muted-foreground"> + Choose one native backend route, keep the APP-Server hidden behind the desktop shell, and enter the workspace without any browser login flow. </p> @@ -236,14 +321,14 @@ export function OpenCodexBackendEditor({ </div> )} - <div className="grid gap-3 md:grid-cols-3"> - {PROVIDER_OPTIONS.map((option) => { - const selected = draft.providerFamily === option.id + <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> + {ROUTE_OPTIONS.map((option) => { + const selected = draft.kind === option.id return ( <button key={option.id} type="button" - onClick={() => updateProvider(option.id)} + onClick={() => updateRouteKind(option.id)} className={cn( "relative rounded-2xl border p-4 text-left transition-colors", selected @@ -265,48 +350,91 @@ export function OpenCodexBackendEditor({ })} </div> - <div className="grid gap-2"> - <Label htmlFor={`${variant}-backend-base-url`}>Base URL</Label> - <Input - id={`${variant}-backend-base-url`} - value={draft.baseUrl} - onChange={(event) => updateField("baseUrl", event.target.value)} - placeholder="https://api.openai.com/v1" - /> - </div> + {draft.kind === "custom-endpoint" && ( + <div className="space-y-3 rounded-xl border border-border bg-background/70 p-4"> + <div> + <div className="text-sm font-medium">Custom Provider Family</div> + <div className="text-xs text-muted-foreground"> + Pick the request semantics the custom endpoint expects. + </div> + </div> + <div className="grid gap-3 md:grid-cols-2"> + {CUSTOM_PROVIDER_OPTIONS.map((option) => { + const selected = draft.providerFamily === option.id + return ( + <button + key={option.id} + type="button" + onClick={() => updateCustomProviderFamily(option.id)} + className={cn( + "rounded-xl border p-3 text-left transition-colors", + selected + ? "border-primary bg-primary/5 shadow-sm" + : "border-border bg-card hover:border-foreground/20", + )} + > + <p className="text-sm font-medium">{option.title}</p> + <p className="mt-1 text-xs leading-5 text-muted-foreground"> + {option.description} + </p> + </button> + ) + })} + </div> + </div> + )} - <div className="grid gap-2"> - <Label htmlFor={`${variant}-backend-model`}>Model</Label> - <Input - id={`${variant}-backend-model`} - value={draft.model} - onChange={(event) => updateField("model", event.target.value)} - placeholder="gpt-5.2" - /> - </div> + {routeNeedsEndpointFields ? ( + <> + <div className="grid gap-2"> + <Label htmlFor={`${variant}-backend-base-url`}>Base URL</Label> + <Input + id={`${variant}-backend-base-url`} + value={"baseUrl" in draft ? draft.baseUrl : ""} + onChange={(event) => updateField("baseUrl", event.target.value)} + placeholder="https://api.openai.com/v1" + /> + </div> - <div className="grid gap-2"> - <Label htmlFor={`${variant}-backend-api-key`}>Credential</Label> - <div className="relative"> - <Input - id={`${variant}-backend-api-key`} - type="password" - value={draft.apiKey} - onChange={(event) => updateField("apiKey", event.target.value)} - placeholder={ - draft.providerFamily === "anthropic-compatible" - ? "sk-ant-..." - : "sk-..." - } - className="pr-10 font-mono" - /> - <KeyRound className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <div className="grid gap-2"> + <Label htmlFor={`${variant}-backend-model`}>Model</Label> + <Input + id={`${variant}-backend-model`} + value={"model" in draft ? draft.model : ""} + onChange={(event) => updateField("model", event.target.value)} + placeholder="gpt-5.2" + /> + </div> + + <div className="grid gap-2"> + <Label htmlFor={`${variant}-backend-api-key`}>Credential</Label> + <div className="relative"> + <Input + id={`${variant}-backend-api-key`} + type="password" + value={"apiKey" in draft ? draft.apiKey : ""} + onChange={(event) => updateField("apiKey", event.target.value)} + placeholder={credentialPlaceholder} + className="pr-10 font-mono" + /> + <KeyRound className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + </div> + <p className="text-xs leading-5 text-muted-foreground"> + OpenCodex stores API-backed route credentials locally and never + requires the user to open OpenHarness terminals. + </p> + </div> + </> + ) : ( + <div className="rounded-xl border border-border bg-background/70 p-4 text-sm text-muted-foreground"> + <div className="font-medium text-foreground">{routeSummary}</div> + <p className="mt-1 leading-6"> + This route uses a local authenticated bridge instead of an inline API + key. OpenCodex saves the selected route now and keeps host launch + materialization behind the desktop-controlled runtime seam. + </p> </div> - <p className="text-xs leading-5 text-muted-foreground"> - The frontend owns this setup. End users never need to see OpenHarness - terminals, Codex login windows, or Claude CLI prompts. - </p> - </div> + )} <div className="rounded-xl border border-border bg-background/70 p-4"> <div className="flex items-center justify-between gap-4"> @@ -373,8 +501,10 @@ export function OpenCodexBackendEditor({ <div className="flex items-center justify-between gap-4"> <p className="text-xs leading-5 text-muted-foreground"> {variant === "onboarding" - ? "OpenCodex opens the workspace only after a valid local backend route exists." - : "Saving the backend applies the route immediately and restarts the hidden APP-Server."} + ? "OpenCodex opens the workspace only after a valid backend route has been saved." + : routeRequiresHost + ? "Saving this route applies it immediately and restarts the hidden APP-Server." + : "Saving this route updates the local runtime bridge choice without exposing provider login UI."} </p> <Button type="button" @@ -383,11 +513,11 @@ export function OpenCodexBackendEditor({ > {isApplyingConfig ? variant === "onboarding" - ? "Starting Local Backend..." + ? "Saving Backend Route..." : "Saving Backend..." : primaryLabel} </Button> </div> </div> ) -} +} \ No newline at end of file diff --git a/src/renderer/features/opencodex/backend-control-view.tsx b/src/renderer/features/opencodex/backend-control-view.tsx index 0d910bd56..c0cd7c91a 100644 --- a/src/renderer/features/opencodex/backend-control-view.tsx +++ b/src/renderer/features/opencodex/backend-control-view.tsx @@ -157,7 +157,7 @@ export function BackendControlView() { try { const result = await disconnectRuntimeMutation.mutateAsync() if (!result.success) { - throw new Error(result.error || "Failed to disconnect runtime.") + throw new Error("error" in result ? result.error : "Failed to disconnect runtime.") } await trpcUtils.opencodex.getBackendSurface.invalidate() toast.success("Runtime disconnected") @@ -295,7 +295,9 @@ export function BackendControlView() { Model </p> <p className="mt-2 text-sm font-medium text-foreground"> - {backendSurface?.backendConfig?.model ?? "Not configured"} + {backendSurface?.backendConfig && "model" in backendSurface.backendConfig + ? backendSurface.backendConfig.model + : "Not configured"} </p> </div> <div className="rounded-xl border border-border/70 bg-background px-4 py-3"> diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index 13ab2095f..5dcb14d9d 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -1,6 +1,12 @@ import { atom } from "jotai" import { atomWithStorage } from "jotai/utils" import { desktopViewAtom as _desktopViewAtom } from "../../features/agents/atoms" +import { + getOpenCodexBackendRouteTemplate, + normalizeOpenCodexBackendRoute as normalizeSharedOpenCodexBackendRoute, + type OpenCodexBackendRoute, + type OpenCodexBackendRouteKind, +} from "../../../shared/opencodex-backend-route" // ============================================ // RE-EXPORT FROM FEATURES/AGENTS/ATOMS (source of truth) @@ -203,17 +209,9 @@ export type CustomClaudeConfig = { baseUrl: string } -export type OpenCodexBackendProviderFamily = - | "openai-compatible" - | "anthropic-compatible" - | "custom" +export type OpenCodexBackendProviderFamily = OpenCodexBackendRouteKind -export type OpenCodexBackendConfig = { - providerFamily: OpenCodexBackendProviderFamily - baseUrl: string - model: string - apiKey: string -} +export type OpenCodexBackendConfig = OpenCodexBackendRoute // Model profile system - support multiple configs export type ModelProfile = { @@ -333,33 +331,7 @@ export function normalizeCustomClaudeConfig( export function normalizeOpenCodexBackendConfig( config: OpenCodexBackendConfig, ): OpenCodexBackendConfig | undefined { - const providerFamily = config.providerFamily - const baseUrl = config.baseUrl.trim() - const model = config.model.trim() - const apiKey = config.apiKey.trim() - - if (!baseUrl || !model || !apiKey) return undefined - - if ( - providerFamily === "openai-compatible" && - !normalizeCodexApiKey(apiKey) - ) { - return undefined - } - - if ( - providerFamily === "anthropic-compatible" && - !(apiKey.startsWith("sk-ant-") && apiKey.length > 20) - ) { - return undefined - } - - return { - providerFamily, - baseUrl, - model, - apiKey, - } + return normalizeSharedOpenCodexBackendRoute(config) } // Get active config (considering network status and auto-fallback) @@ -780,12 +752,7 @@ export const billingMethodAtom = atomWithStorage<BillingMethod>( export const openCodexBackendConfigAtom = atomWithStorage<OpenCodexBackendConfig>( "opencodex:backend-config", - { - providerFamily: "openai-compatible", - baseUrl: "https://api.openai.com/v1", - model: "gpt-5.2", - apiKey: "", - }, + getOpenCodexBackendRouteTemplate("openai-compatible-api"), undefined, { getOnInit: true }, ) diff --git a/src/shared/opencodex-backend-route.test.ts b/src/shared/opencodex-backend-route.test.ts new file mode 100644 index 000000000..d9de90b7b --- /dev/null +++ b/src/shared/opencodex-backend-route.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { + getOpenCodexBackendRouteTemplate, + getOpenCodexBackendRuntimeKind, + normalizeOpenCodexBackendRoute, + openCodexBackendRouteRequiresHost, + parseOpenCodexBackendRoute, +} from "./opencodex-backend-route" + +describe("OpenCodex backend route helpers", () => { + test("normalizes the approved subscription bridge route variants", () => { + expect( + normalizeOpenCodexBackendRoute({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }), + ).toEqual({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }) + + expect( + normalizeOpenCodexBackendRoute({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }), + ).toEqual({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }) + }) + + test("migrates the legacy narrow config shape into the canonical union route", () => { + expect( + parseOpenCodexBackendRoute({ + providerFamily: "openai-compatible", + baseUrl: " https://api.openai.com/v1 ", + model: " gpt-5.2 ", + apiKey: " sk-route-openai ", + }), + ).toEqual({ + kind: "openai-compatible-api", + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-route-openai", + }) + + expect( + parseOpenCodexBackendRoute({ + providerFamily: "custom", + baseUrl: " http://127.0.0.1:8000/v1 ", + model: " opencodex-default ", + apiKey: " sk-custom-endpoint ", + }), + ).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "sk-custom-endpoint", + }) + }) + + test("rejects invalid api-backed routes and identifies which routes need host launch materialization", () => { + expect( + normalizeOpenCodexBackendRoute({ + kind: "anthropic-compatible-api", + authSource: "api-key", + baseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-6", + apiKey: "sk-short", + }), + ).toBeUndefined() + + expect( + openCodexBackendRouteRequiresHost({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }), + ).toBe(false) + + expect( + openCodexBackendRouteRequiresHost({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "anthropic-compatible", + baseUrl: "https://proxy.example.com", + model: "claude-opus", + apiKey: "sk-ant-123456789012345678901", + }), + ).toBe(true) + }) + + test("provides stable templates and runtime mapping for all route kinds", () => { + expect(getOpenCodexBackendRouteTemplate("codex-subscription")).toEqual({ + kind: "codex-subscription", + authSource: "codex-local-auth", + }) + + expect(getOpenCodexBackendRouteTemplate("custom-endpoint")).toEqual({ + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "", + }) + + expect( + getOpenCodexBackendRuntimeKind({ + kind: "openai-compatible-api", + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "sk-openai-route", + }), + ).toBe("codex") + + expect( + getOpenCodexBackendRuntimeKind({ + kind: "claude-subscription", + authSource: "claude-local-auth", + }), + ).toBe("claude") + }) +}) \ No newline at end of file diff --git a/src/shared/opencodex-backend-route.ts b/src/shared/opencodex-backend-route.ts new file mode 100644 index 000000000..82d620d91 --- /dev/null +++ b/src/shared/opencodex-backend-route.ts @@ -0,0 +1,314 @@ +export type OpenCodexBackendProviderFamily = + | "openai-compatible" + | "anthropic-compatible" + +export type OpenCodexBackendRouteKind = + | "codex-subscription" + | "claude-subscription" + | "openai-compatible-api" + | "anthropic-compatible-api" + | "custom-endpoint" + +export type OpenCodexBackendRoute = + | { + kind: "codex-subscription" + authSource: "codex-local-auth" + } + | { + kind: "claude-subscription" + authSource: "claude-local-auth" + } + | { + kind: "openai-compatible-api" + authSource: "api-key" + baseUrl: string + model: string + apiKey: string + } + | { + kind: "anthropic-compatible-api" + authSource: "api-key" + baseUrl: string + model: string + apiKey: string + } + | { + kind: "custom-endpoint" + authSource: "api-key" + providerFamily: OpenCodexBackendProviderFamily + baseUrl: string + model: string + apiKey: string + } + +type LegacyOpenCodexBackendConfig = { + providerFamily: "openai-compatible" | "anthropic-compatible" | "custom" + baseUrl: string + model: string + apiKey: string +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null + } + + return value.trim() +} + +function isValidOpenAiStyleKey(apiKey: string): boolean { + return apiKey.startsWith("sk-") +} + +function isValidAnthropicKey(apiKey: string): boolean { + return apiKey.startsWith("sk-ant-") && apiKey.length > 20 +} + +function normalizeApiRouteFields( + input: Record<string, unknown>, +): { + baseUrl: string + model: string + apiKey: string +} | null { + const baseUrl = normalizeText(input.baseUrl) + const model = normalizeText(input.model) + const apiKey = normalizeText(input.apiKey) + + if (!baseUrl || !model || !apiKey) { + return null + } + + return { baseUrl, model, apiKey } +} + +function normalizeUnionRoute(input: Record<string, unknown>): OpenCodexBackendRoute | null { + const kind = normalizeText(input.kind) as OpenCodexBackendRouteKind | null + if (!kind) { + return null + } + + switch (kind) { + case "codex-subscription": + return input.authSource === "codex-local-auth" + ? { kind, authSource: "codex-local-auth" } + : null + case "claude-subscription": + return input.authSource === "claude-local-auth" + ? { kind, authSource: "claude-local-auth" } + : null + case "openai-compatible-api": { + if (input.authSource !== "api-key") { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields || !isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + return { kind, authSource: "api-key", ...fields } + } + case "anthropic-compatible-api": { + if (input.authSource !== "api-key") { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields || !isValidAnthropicKey(fields.apiKey)) { + return null + } + return { kind, authSource: "api-key", ...fields } + } + case "custom-endpoint": { + if (input.authSource !== "api-key") { + return null + } + const providerFamily = normalizeText( + input.providerFamily, + ) as OpenCodexBackendProviderFamily | null + if ( + providerFamily !== "openai-compatible" && + providerFamily !== "anthropic-compatible" + ) { + return null + } + const fields = normalizeApiRouteFields(input) + if (!fields) { + return null + } + const isValidKey = + providerFamily === "anthropic-compatible" + ? isValidAnthropicKey(fields.apiKey) + : isValidOpenAiStyleKey(fields.apiKey) + if (!isValidKey) { + return null + } + return { kind, authSource: "api-key", providerFamily, ...fields } + } + } +} + +function normalizeLegacyRoute( + input: Record<string, unknown>, +): OpenCodexBackendRoute | null { + const providerFamily = normalizeText(input.providerFamily) + if ( + providerFamily !== "openai-compatible" && + providerFamily !== "anthropic-compatible" && + providerFamily !== "custom" + ) { + return null + } + + const fields = normalizeApiRouteFields(input) + if (!fields) { + return null + } + + if (providerFamily === "openai-compatible") { + if (!isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + return { + kind: "openai-compatible-api", + authSource: "api-key", + ...fields, + } + } + + if (providerFamily === "anthropic-compatible") { + if (!isValidAnthropicKey(fields.apiKey)) { + return null + } + return { + kind: "anthropic-compatible-api", + authSource: "api-key", + ...fields, + } + } + + if (!isValidOpenAiStyleKey(fields.apiKey)) { + return null + } + + return { + kind: "custom-endpoint", + authSource: "api-key", + providerFamily: "openai-compatible", + ...fields, + } +} + +export function getOpenCodexBackendRouteTemplate( + kind: OpenCodexBackendRouteKind, +): OpenCodexBackendRoute { + switch (kind) { + case "codex-subscription": + return { kind, authSource: "codex-local-auth" } + case "claude-subscription": + return { kind, authSource: "claude-local-auth" } + case "anthropic-compatible-api": + return { + kind, + authSource: "api-key", + baseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-6", + apiKey: "", + } + case "custom-endpoint": + return { + kind, + authSource: "api-key", + providerFamily: "openai-compatible", + baseUrl: "http://127.0.0.1:8000/v1", + model: "opencodex-default", + apiKey: "", + } + default: + return { + kind, + authSource: "api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5.2", + apiKey: "", + } + } +} + +export function normalizeOpenCodexBackendRoute( + route: OpenCodexBackendRoute, +): OpenCodexBackendRoute | undefined { + return parseOpenCodexBackendRoute(route) ?? undefined +} + +export function parseOpenCodexBackendRoute( + value: unknown, +): OpenCodexBackendRoute | null { + if (!isRecord(value)) { + return null + } + + if ("kind" in value) { + return normalizeUnionRoute(value) + } + + return normalizeLegacyRoute(value) +} + +export function getOpenCodexBackendRouteTitle( + route: OpenCodexBackendRoute, +): string { + switch (route.kind) { + case "codex-subscription": + return "Codex Subscription" + case "claude-subscription": + return "Claude Subscription" + case "openai-compatible-api": + return "OpenAI-Compatible API" + case "anthropic-compatible-api": + return "Anthropic-Compatible API" + case "custom-endpoint": + return "Custom Endpoint" + } +} + +export function getOpenCodexBackendProviderFamily( + route: OpenCodexBackendRoute, +): OpenCodexBackendProviderFamily | null { + switch (route.kind) { + case "openai-compatible-api": + return "openai-compatible" + case "anthropic-compatible-api": + return "anthropic-compatible" + case "custom-endpoint": + return route.providerFamily + default: + return null + } +} + +export function getOpenCodexBackendRuntimeKind( + route: OpenCodexBackendRoute, +): "codex" | "claude" { + switch (route.kind) { + case "codex-subscription": + case "openai-compatible-api": + return "codex" + default: + return "claude" + } +} + +export function openCodexBackendRouteRequiresHost( + route: OpenCodexBackendRoute, +): boolean { + return ( + route.kind === "openai-compatible-api" || + route.kind === "anthropic-compatible-api" || + route.kind === "custom-endpoint" + ) +} + +export type { LegacyOpenCodexBackendConfig } \ No newline at end of file