From 45f6f17eb0b0e1e6f28a9a77069397255c3b6e8f Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 15:47:18 +0100 Subject: [PATCH 01/14] fix(docker): Pre-install Playwright Chromium browsers for automated testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #725 AI agents in automated testing mode require Playwright to verify implementations, but Docker containers had only system dependencies installed, not browser binaries. This caused verification failures with permissions errors. Changes: - Install Playwright Chromium in Dockerfile (~300MB increase) - Update docker-compose.override.yml.example with clearer Playwright documentation - Add "Playwright for Automated Testing" section to README - Document optional volume mount for persisting browsers across rebuilds Browsers are now pre-installed and work out of the box for Docker users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Dockerfile | 6 ++++++ README.md | 27 +++++++++++++++++++++++++++ docker-compose.override.yml.example | 11 ++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 03911b45..7d22858c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,6 +118,12 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ echo "=== Checking OpenCode CLI installation ===" && \ ls -la /home/automaker/.local/bin/ && \ (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" + +# Install Playwright Chromium browser for AI agent verification tests +# This adds ~300MB to the image but enables automated testing mode out of the box +RUN npx playwright install chromium && \ + echo "=== Playwright Chromium installed ===" && \ + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" USER root # Add PATH to profile so it's available in all interactive shells (for login shells) diff --git a/README.md b/README.md index 75705673..645ba722 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,33 @@ services: The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build. +##### Playwright for Automated Testing + +The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly. + +**No additional setup required** - Playwright verification works out of the box. + +**Optional: Persist browsers across container rebuilds** + +To avoid re-downloading browsers when rebuilding the Docker image, add this to your `docker-compose.override.yml`: + +```yaml +services: + server: + volumes: + - playwright-cache:/home/automaker/.cache/ms-playwright + +volumes: + playwright-cache: + name: automaker-playwright-cache +``` + +**Updating browsers manually:** + +```bash +docker exec automaker-server npx playwright install chromium +``` + ### Testing #### End-to-End Tests (Playwright) diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 3815c197..d1f0c216 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -21,9 +21,13 @@ services: # - ~/.local/share/opencode:/home/automaker/.local/share/opencode # - ~/.config/opencode:/home/automaker/.config/opencode - # Playwright browser cache - persists installed browsers across container restarts - # Run 'npx playwright install --with-deps chromium' once, and it will persist + # ===== Playwright Browser Cache (Optional) ===== + # Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing. + # Uncomment below to persist browser cache across container rebuilds (saves ~300MB download): # - playwright-cache:/home/automaker/.cache/ms-playwright + # + # To update Playwright browsers manually: + # docker exec automaker-server npx playwright install chromium environment: # Set root directory for all projects and file operations # Users can only create/open projects within this directory @@ -37,6 +41,7 @@ services: # - CURSOR_API_KEY=${CURSOR_API_KEY:-} volumes: - # Playwright cache volume (persists Chromium installs) + # Playwright cache volume - optional, persists browser updates across container rebuilds + # Uncomment if you mounted the playwright-cache volume above # playwright-cache: # name: automaker-playwright-cache From b37a287c9c63b6955e454e8497a16746a2231712 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 15:55:11 +0100 Subject: [PATCH 02/14] fix(docker): Address PR #745 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clean up npx cache after Playwright installation to reduce image size - Clarify README: volume mounts persist cache across container lifecycles, not image rebuilds - Add first-use warning: empty volume overrides pre-installed browsers, users must re-install with docker exec command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 4 +++- README.md | 13 +++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7d22858c..f5c3511e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,9 +121,11 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ # Install Playwright Chromium browser for AI agent verification tests # This adds ~300MB to the image but enables automated testing mode out of the box +# Clean up npx cache after installation to reduce image size RUN npx playwright install chromium && \ echo "=== Playwright Chromium installed ===" && \ - ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" && \ + rm -rf /home/automaker/.npm/_npx USER root # Add PATH to profile so it's available in all interactive shells (for login shells) diff --git a/README.md b/README.md index 645ba722..0c21245b 100644 --- a/README.md +++ b/README.md @@ -344,9 +344,18 @@ The Docker image includes **Playwright Chromium pre-installed** for AI agent ver **No additional setup required** - Playwright verification works out of the box. -**Optional: Persist browsers across container rebuilds** +**Optional: Persist browsers for manual updates** -To avoid re-downloading browsers when rebuilding the Docker image, add this to your `docker-compose.override.yml`: +By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume. + +**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them: + +```bash +# After adding the volume mount for the first time +docker exec automaker-server npx playwright install chromium +``` + +Add this to your `docker-compose.override.yml`: ```yaml services: From 3ccea7a67beee54f06060ecc4cfb8ebb0c307673 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 16:07:53 +0100 Subject: [PATCH 03/14] fix(docker): Address remaining PR #745 review comments - Move Playwright install after node_modules copy to use pinned version - Use local playwright binary instead of npx to avoid registry fetch - Add --user automaker -w /app flags to docker exec commands - Change bold text to proper heading in README (MD036 lint fix) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 16 +++++++++------- README.md | 6 +++--- docker-compose.override.yml.example | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index f5c3511e..2e745e4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,13 +119,6 @@ RUN curl -fsSL https://opencode.ai/install | bash && \ ls -la /home/automaker/.local/bin/ && \ (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" -# Install Playwright Chromium browser for AI agent verification tests -# This adds ~300MB to the image but enables automated testing mode out of the box -# Clean up npx cache after installation to reduce image size -RUN npx playwright install chromium && \ - echo "=== Playwright Chromium installed ===" && \ - ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" && \ - rm -rf /home/automaker/.npm/_npx USER root # Add PATH to profile so it's available in all interactive shells (for login shells) @@ -155,6 +148,15 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/ # Copy node_modules (includes symlinks to libs) COPY --from=server-builder /app/node_modules ./node_modules +# Install Playwright Chromium browser for AI agent verification tests +# This adds ~300MB to the image but enables automated testing mode out of the box +# Using the locally installed playwright ensures we use the pinned version from package-lock.json +USER automaker +RUN ./node_modules/.bin/playwright install chromium && \ + echo "=== Playwright Chromium installed ===" && \ + ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed" +USER root + # Create data and projects directories RUN mkdir -p /data /projects && chown automaker:automaker /data /projects diff --git a/README.md b/README.md index 0c21245b..95beefe1 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ The Docker image includes **Playwright Chromium pre-installed** for AI agent ver **No additional setup required** - Playwright verification works out of the box. -**Optional: Persist browsers for manual updates** +#### Optional: Persist browsers for manual updates By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume. @@ -352,7 +352,7 @@ By default, Playwright Chromium is pre-installed in the Docker image. If you nee ```bash # After adding the volume mount for the first time -docker exec automaker-server npx playwright install chromium +docker exec --user automaker -w /app automaker-server npx playwright install chromium ``` Add this to your `docker-compose.override.yml`: @@ -371,7 +371,7 @@ volumes: **Updating browsers manually:** ```bash -docker exec automaker-server npx playwright install chromium +docker exec --user automaker -w /app automaker-server npx playwright install chromium ``` ### Testing diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index d1f0c216..e92ce119 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -27,7 +27,7 @@ services: # - playwright-cache:/home/automaker/.cache/ms-playwright # # To update Playwright browsers manually: - # docker exec automaker-server npx playwright install chromium + # docker exec --user automaker -w /app automaker-server npx playwright install chromium environment: # Set root directory for all projects and file operations # Users can only create/open projects within this directory From aad3ff2cdf74b7f872dc57eb8aa8117dae3b6952 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:35:03 +0100 Subject: [PATCH 04/14] fix(auth): Improve OAuth credential detection and startup warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced getClaudeAuthIndicators() to return detailed check information including file paths checked and specific error details for debugging - Added debug logging to server startup credential detection for easier troubleshooting in Docker environments - Show paths that were checked in the warning message to help users debug mount issues - Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable - Return authType in verify-claude-auth response to distinguish between OAuth and CLI authentication methods - Updated UI to show specific success messages for Claude Code subscription vs generic CLI auth - Added Docker troubleshooting tips to sandbox risk dialog - Added comprehensive unit tests for OAuth credential detection scenarios Closes #721 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/index.ts | 101 ++- .../routes/setup/routes/verify-claude-auth.ts | 19 + .../dialogs/sandbox-risk-dialog.tsx | 23 + .../setup-view/steps/claude-setup-step.tsx | 25 +- apps/ui/src/lib/electron.ts | 1 + apps/ui/src/lib/http-api-client.ts | 1 + libs/platform/src/index.ts | 2 + libs/platform/src/system-paths.ts | 156 +++- .../tests/oauth-credential-detection.test.ts | 736 ++++++++++++++++++ 9 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 libs/platform/tests/oauth-credential-detection.test.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc..c10702bb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67; // The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication (async () => { const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); + logger.debug('[CREDENTIAL_CHECK] Environment variables:', { + hasAnthropicKey, + hasEnvOAuthToken, + }); if (hasAnthropicKey) { logger.info('✓ ANTHROPIC_API_KEY detected'); return; } + if (hasEnvOAuthToken) { + logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected'); + return; + } + // Check for Claude Code CLI authentication + // Store indicators outside the try block so we can use them in the warning message + let cliAuthIndicators: Awaited> | null = null; + try { - const indicators = await getClaudeAuthIndicators(); + cliAuthIndicators = await getClaudeAuthIndicators(); + const indicators = cliAuthIndicators; + + // Log detailed credential detection results + logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', { + hasCredentialsFile: indicators.hasCredentialsFile, + hasSettingsFile: indicators.hasSettingsFile, + hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity, + hasProjectsSessions: indicators.hasProjectsSessions, + credentials: indicators.credentials, + }); + + logger.debug('[CREDENTIAL_CHECK] File check details:', { + settingsFile: { + path: indicators.checks.settingsFile.path, + exists: indicators.checks.settingsFile.exists, + readable: indicators.checks.settingsFile.readable, + error: indicators.checks.settingsFile.error, + }, + statsCache: { + path: indicators.checks.statsCache.path, + exists: indicators.checks.statsCache.exists, + readable: indicators.checks.statsCache.readable, + hasDailyActivity: indicators.checks.statsCache.hasDailyActivity, + error: indicators.checks.statsCache.error, + }, + projectsDir: { + path: indicators.checks.projectsDir.path, + exists: indicators.checks.projectsDir.exists, + readable: indicators.checks.projectsDir.readable, + entryCount: indicators.checks.projectsDir.entryCount, + error: indicators.checks.projectsDir.error, + }, + credentialFiles: indicators.checks.credentialFiles.map((cf) => ({ + path: cf.path, + exists: cf.exists, + readable: cf.readable, + error: cf.error, + })), + }); + const hasCliAuth = indicators.hasStatsCacheWithActivity || (indicators.hasSettingsFile && indicators.hasProjectsSessions) || (indicators.hasCredentialsFile && (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); + logger.debug('[CREDENTIAL_CHECK] Auth determination:', { + hasCliAuth, + reason: hasCliAuth + ? indicators.hasStatsCacheWithActivity + ? 'stats cache with activity' + : indicators.hasSettingsFile && indicators.hasProjectsSessions + ? 'settings file + project sessions' + : indicators.credentials?.hasOAuthToken + ? 'credentials file with OAuth token' + : 'credentials file with API key' + : 'no valid credentials found', + }); + if (hasCliAuth) { logger.info('✓ Claude Code CLI authentication detected'); return; @@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67; logger.warn('Error checking for Claude Code CLI authentication:', error); } - // No authentication found - show warning + // No authentication found - show warning with paths that were checked const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); @@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67; BOX_CONTENT_WIDTH ); + // Build paths checked summary from the indicators (if available) + let pathsCheckedInfo = ''; + if (cliAuthIndicators) { + const pathsChecked: string[] = []; + + // Collect paths that were checked + if (cliAuthIndicators.checks.settingsFile.path) { + pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); + } + if (cliAuthIndicators.checks.statsCache.path) { + pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); + } + if (cliAuthIndicators.checks.projectsDir.path) { + pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); + } + for (const credFile of cliAuthIndicators.checks.credentialFiles) { + pathsChecked.push(`Credentials: ${credFile.path}`); + } + + if (pathsChecked.length > 0) { + pathsCheckedInfo = ` +║ ║ +║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║ +${pathsChecked.map((p) => `║ ${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)} ║`).join('\n')}`; + } + } + logger.warn(` ╔═════════════════════════════════════════════════════════════════════╗ ║ ${wHeader}║ @@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67; ║ ${w3}║ ║ ${w4}║ ║ ${w5}║ -║ ${w6}║ +║ ${w6}║${pathsCheckedInfo} ║ ║ ╚═════════════════════════════════════════════════════════════════════╝ `); diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index df04d462..2a8d21b0 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() { authMethod, }); + // Determine specific auth type for success messages + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (authMethod === 'api_key') { + authType = 'api_key'; + } else if (authMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + // OAuth tokens are stored in the credentials file by the Claude CLI + const { getClaudeAuthIndicators } = await import('@automaker/platform'); + const indicators = await getClaudeAuthIndicators(); + if (indicators.credentials?.hasOAuthToken) { + authType = 'oauth'; + } else { + authType = 'cli'; + } + } + } + res.json({ success: true, authenticated, + authType, error: errorMessage || undefined, }); } catch (error) { diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx index 3a5f6d35..7b597c8c 100644 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog For safer operation, consider running Automaker in Docker. See the README for instructions.

+ +
+

+ Already running in Docker? Try these troubleshooting steps: +

+
    +
  • + Ensure IS_CONTAINERIZED=true is + set in your docker-compose environment +
  • +
  • + Verify the server container has the environment variable:{' '} + + docker exec automaker-server printenv IS_CONTAINERIZED + +
  • +
  • Rebuild and restart containers if you recently changed the configuration
  • +
  • + Check the server logs for startup messages:{' '} + docker-compose logs server +
  • +
+
diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index b864bfdb..0b4799d6 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps // CLI Verification state const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); const [cliVerificationError, setCliVerificationError] = useState(null); + const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null); // API Key Verification state const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = @@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps const verifyCliAuth = useCallback(async () => { setCliVerificationStatus('verifying'); setCliVerificationError(null); + setCliAuthType(null); try { const api = getElectronAPI(); @@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps if (result.authenticated && !hasLimitReachedError) { setCliVerificationStatus('verified'); + // Store the auth type for displaying specific success message + const authType = result.authType === 'oauth' ? 'oauth' : 'cli'; + setCliAuthType(authType); setClaudeAuthStatus({ authenticated: true, - method: 'cli_authenticated', + method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated', hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + oauthTokenValid: authType === 'oauth', }); - toast.success('Claude CLI authentication verified!'); + // Show specific success message based on auth type + if (authType === 'oauth') { + toast.success('Claude Code subscription detected and verified!'); + } else { + toast.success('Claude CLI authentication verified!'); + } } else { setCliVerificationStatus('error'); setCliVerificationError( @@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
-

CLI Authentication verified!

+

+ {cliAuthType === 'oauth' + ? 'Claude Code subscription verified!' + : 'CLI Authentication verified!'} +

- Your Claude CLI is working correctly. + {cliAuthType === 'oauth' + ? 'Your Claude Code subscription is active and ready to use.' + : 'Your Claude CLI is working correctly.'}

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 89aa07ba..22079822 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1442,6 +1442,7 @@ interface SetupAPI { verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }>; getGhStatus?: () => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1f79ff07..acd75d22 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }), diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 5952ba2d..5c0b8078 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -134,6 +134,8 @@ export { findClaudeCliPath, getClaudeAuthIndicators, type ClaudeAuthIndicators, + type FileCheckResult, + type DirectoryCheckResult, findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa..fb5e6bd3 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -976,6 +976,27 @@ export async function findGitBashPath(): Promise { return findFirstExistingPath(getGitBashPaths()); } +/** + * Details about a file check performed during auth detection + */ +export interface FileCheckResult { + path: string; + exists: boolean; + readable: boolean; + error?: string; +} + +/** + * Details about a directory check performed during auth detection + */ +export interface DirectoryCheckResult { + path: string; + exists: boolean; + readable: boolean; + entryCount: number; + error?: string; +} + /** * Get Claude authentication status by checking various indicators */ @@ -988,67 +1009,144 @@ export interface ClaudeAuthIndicators { hasOAuthToken: boolean; hasApiKey: boolean; } | null; + /** Detailed information about what was checked */ + checks: { + settingsFile: FileCheckResult; + statsCache: FileCheckResult & { hasDailyActivity?: boolean }; + projectsDir: DirectoryCheckResult; + credentialFiles: FileCheckResult[]; + }; } export async function getClaudeAuthIndicators(): Promise { + const settingsPath = getClaudeSettingsPath(); + const statsCachePath = getClaudeStatsCachePath(); + const projectsDir = getClaudeProjectsDir(); + const credentialPaths = getClaudeCredentialPaths(); + + // Initialize checks with paths + const settingsFileCheck: FileCheckResult = { + path: settingsPath, + exists: false, + readable: false, + }; + + const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = { + path: statsCachePath, + exists: false, + readable: false, + }; + + const projectsDirCheck: DirectoryCheckResult = { + path: projectsDir, + exists: false, + readable: false, + entryCount: 0, + }; + + const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({ + path: p, + exists: false, + readable: false, + })); + const result: ClaudeAuthIndicators = { hasCredentialsFile: false, hasSettingsFile: false, hasStatsCacheWithActivity: false, hasProjectsSessions: false, credentials: null, + checks: { + settingsFile: settingsFileCheck, + statsCache: statsCacheCheck, + projectsDir: projectsDirCheck, + credentialFiles: credentialFileChecks, + }, }; // Check settings file try { - if (await systemPathAccess(getClaudeSettingsPath())) { + if (await systemPathAccess(settingsPath)) { + settingsFileCheck.exists = true; + settingsFileCheck.readable = true; result.hasSettingsFile = true; } - } catch { - // Ignore errors + } catch (err) { + settingsFileCheck.error = err instanceof Error ? err.message : String(err); } // Check stats cache for recent activity try { - const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); - const stats = JSON.parse(statsContent); - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - result.hasStatsCacheWithActivity = true; + const statsContent = await systemPathReadFile(statsCachePath); + statsCacheCheck.exists = true; + statsCacheCheck.readable = true; + try { + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + statsCacheCheck.hasDailyActivity = true; + result.hasStatsCacheWithActivity = true; + } else { + statsCacheCheck.hasDailyActivity = false; + } + } catch (parseErr) { + statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + statsCacheCheck.exists = false; + } else { + statsCacheCheck.error = err instanceof Error ? err.message : String(err); } - } catch { - // Ignore errors } // Check for sessions in projects directory try { - const sessions = await systemPathReaddir(getClaudeProjectsDir()); + const sessions = await systemPathReaddir(projectsDir); + projectsDirCheck.exists = true; + projectsDirCheck.readable = true; + projectsDirCheck.entryCount = sessions.length; if (sessions.length > 0) { result.hasProjectsSessions = true; } - } catch { - // Ignore errors + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + projectsDirCheck.exists = false; + } else { + projectsDirCheck.error = err instanceof Error ? err.message : String(err); + } } // Check credentials files - const credentialPaths = getClaudeCredentialPaths(); - for (const credPath of credentialPaths) { + for (let i = 0; i < credentialPaths.length; i++) { + const credPath = credentialPaths[i]; + const credCheck = credentialFileChecks[i]; try { const content = await systemPathReadFile(credPath); - const credentials = JSON.parse(content); - result.hasCredentialsFile = true; - // Support multiple credential formats: - // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } - // 2. Legacy format: { oauth_token } or { access_token } - // 3. API key format: { api_key } - const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; - const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; - } catch { - // Continue to next path + credCheck.exists = true; + credCheck.readable = true; + try { + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + // Support multiple credential formats: + // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } + // 2. Legacy format: { oauth_token } or { access_token } + // 3. API key format: { api_key } + const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; + const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); + result.credentials = { + hasOAuthToken: hasClaudeOauth || hasLegacyOauth, + hasApiKey: !!credentials.api_key, + }; + break; + } catch (parseErr) { + credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + credCheck.exists = false; + } else { + credCheck.error = err instanceof Error ? err.message : String(err); + } } } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts new file mode 100644 index 00000000..cf5a4705 --- /dev/null +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -0,0 +1,736 @@ +/** + * Unit tests for OAuth credential detection scenarios + * + * Tests the various Claude credential detection formats including: + * - Claude Code CLI OAuth format (claudeAiOauth) + * - Legacy OAuth token format (oauth_token, access_token) + * - API key format (api_key) + * - Invalid/malformed credential files + * + * These tests use real temp directories to avoid complex fs mocking issues. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('OAuth Credential Detection', () => { + let tempDir: string; + let originalHomedir: () => string; + let mockClaudeDir: string; + let mockCodexDir: string; + let mockOpenCodeDir: string; + + beforeEach(async () => { + // Reset modules to get fresh state + vi.resetModules(); + + // Create a temporary directory + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-')); + + // Create mock home directory structure + mockClaudeDir = path.join(tempDir, '.claude'); + mockCodexDir = path.join(tempDir, '.codex'); + mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode'); + + await fs.mkdir(mockClaudeDir, { recursive: true }); + await fs.mkdir(mockCodexDir, { recursive: true }); + await fs.mkdir(mockOpenCodeDir, { recursive: true }); + + // Mock os.homedir to return our temp directory + originalHomedir = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getClaudeAuthIndicators', () => { + it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-access-token-12345', + refreshToken: 'oauth-refresh-token-67890', + expiresAt: Date.now() + 3600000, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy OAuth token format (oauth_token)', async () => { + const credentialsContent = JSON.stringify({ + oauth_token: 'legacy-oauth-token-abcdef', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy access_token format', async () => { + const credentialsContent = JSON.stringify({ + access_token: 'legacy-access-token-xyz', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect API key format', async () => { + const credentialsContent = JSON.stringify({ + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should detect both OAuth and API key when present', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle missing credentials file gracefully', async () => { + // No credentials file created + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0); + expect(indicators.checks.credentialFiles[0].exists).toBe(false); + }); + + it('should handle malformed JSON in credentials file', async () => { + const malformedContent = '{ invalid json }'; + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // File exists but parsing fails + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error'); + }); + + it('should handle empty credentials file', async () => { + const emptyContent = JSON.stringify({}); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials file with null values', async () => { + const nullContent = JSON.stringify({ + claudeAiOauth: null, + api_key: null, + oauth_token: null, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials with empty string values', async () => { + const emptyStrings = JSON.stringify({ + claudeAiOauth: { + accessToken: '', + refreshToken: '', + }, + api_key: '', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + // Empty strings should not be treated as valid credentials + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect settings file presence', async () => { + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.checks.settingsFile.exists).toBe(true); + expect(indicators.checks.settingsFile.readable).toBe(true); + }); + + it('should detect stats cache with activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [ + { date: '2025-01-15', messagesCount: 10 }, + { date: '2025-01-16', messagesCount: 5 }, + ], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(true); + }); + + it('should detect stats cache without activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(false); + }); + + it('should detect project sessions', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + await fs.mkdir(path.join(projectsDir, 'session-2')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(2); + }); + + it('should return comprehensive check details', async () => { + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Verify all check detail objects are present + expect(indicators.checks).toBeDefined(); + expect(indicators.checks.settingsFile).toBeDefined(); + expect(indicators.checks.settingsFile.path).toContain('settings.json'); + expect(indicators.checks.statsCache).toBeDefined(); + expect(indicators.checks.statsCache.path).toContain('stats-cache.json'); + expect(indicators.checks.projectsDir).toBeDefined(); + expect(indicators.checks.projectsDir.path).toContain('projects'); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true); + }); + + it('should try both .credentials.json and credentials.json paths', async () => { + // Write to credentials.json (without leading dot) + const credentialsContent = JSON.stringify({ + api_key: 'sk-test-key', + }); + + await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in the second path + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should prefer first credentials file if both exist', async () => { + // Write OAuth to .credentials.json (first path checked) + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + }) + ); + + // Write API key to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should use first file (.credentials.json) which has OAuth + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + }); + + describe('getCodexAuthIndicators', () => { + it('should detect OAuth token in Codex auth file', async () => { + const authContent = JSON.stringify({ + access_token: 'codex-oauth-token-12345', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect API key in Codex auth file', async () => { + const authContent = JSON.stringify({ + OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect nested tokens in Codex auth file', async () => { + const authContent = JSON.stringify({ + tokens: { + oauth_token: 'nested-oauth-token', + }, + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should handle missing Codex auth file', async () => { + // No auth file created + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect api_key field in Codex auth', async () => { + const authContent = JSON.stringify({ + api_key: 'sk-api-key-value', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + }); + + describe('getOpenCodeAuthIndicators', () => { + it('should detect provider-specific OAuth credentials', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'oauth-access-token', + refresh: 'oauth-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect GitHub Copilot refresh token as OAuth', async () => { + const authContent = JSON.stringify({ + 'github-copilot': { + type: 'oauth', + access: '', // Empty access token + refresh: 'gh-refresh-token', // But has refresh token + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect provider-specific API key credentials', async () => { + const authContent = JSON.stringify({ + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect multiple providers', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'anthropic-token', + refresh: 'refresh-token', + }, + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should handle missing OpenCode auth file', async () => { + // No auth file created + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should handle legacy top-level OAuth keys', async () => { + const authContent = JSON.stringify({ + access_token: 'legacy-access-token', + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect copilot provider OAuth', async () => { + const authContent = JSON.stringify({ + copilot: { + type: 'oauth', + access: 'copilot-access-token', + refresh: 'copilot-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + }); + + describe('Credential path helpers', () => { + it('should return correct Claude credential paths', async () => { + const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths'); + + const configDir = getClaudeConfigDir(); + expect(configDir).toContain('.claude'); + + const credPaths = getClaudeCredentialPaths(); + expect(credPaths.length).toBeGreaterThan(0); + expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true); + expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true); + }); + + it('should return correct Codex auth path', async () => { + const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths'); + + const configDir = getCodexConfigDir(); + expect(configDir).toContain('.codex'); + + const authPath = getCodexAuthPath(); + expect(authPath).toContain('.codex'); + expect(authPath).toContain('auth.json'); + }); + + it('should return correct OpenCode auth path', async () => { + const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths'); + + const configDir = getOpenCodeConfigDir(); + expect(configDir).toContain('opencode'); + + const authPath = getOpenCodeAuthPath(); + expect(authPath).toContain('opencode'); + expect(authPath).toContain('auth.json'); + }); + }); + + describe('Edge cases for credential detection', () => { + it('should handle credentials file with unexpected structure', async () => { + const unexpectedContent = JSON.stringify({ + someUnexpectedKey: 'value', + nested: { + deeply: { + unexpected: true, + }, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle array instead of object in credentials', async () => { + const arrayContent = JSON.stringify(['token1', 'token2']); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Array is valid JSON but wrong structure - should handle gracefully + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle numeric values in credential fields', async () => { + const numericContent = JSON.stringify({ + api_key: 12345, + oauth_token: 67890, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness which accepts numbers + // This documents the actual behavior - ideally would validate string type + expect(indicators.hasCredentialsFile).toBe(true); + // The implementation checks truthiness, not strict string type + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle boolean values in credential fields', async () => { + const booleanContent = JSON.stringify({ + api_key: true, + oauth_token: false, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness + // api_key: true is truthy, oauth_token: false is falsy + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy + expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy + }); + + it('should handle malformed stats-cache.json gracefully', async () => { + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }'); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.error).toBeDefined(); + }); + + it('should handle empty projects directory', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(false); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(0); + }); + }); + + describe('Combined authentication scenarios', () => { + it('should detect CLI authenticated state with settings + sessions', async () => { + // Create settings file + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + // Create projects directory with sessions + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + }); + + it('should detect recent activity indicating working auth', async () => { + // Create stats cache with recent activity + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ + dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }], + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + }); + + it('should handle complete auth setup', async () => { + // Create all auth indicators + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'token', + refreshToken: 'refresh', + }, + }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] }) + ); + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + }); + }); +}); From 0aef72540e5f9eff7919f5b2157ce7c5f1141204 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:54:23 +0100 Subject: [PATCH 05/14] fix(auth): Enhance credential detection logic for OAuth - Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths. - Improved error handling for settings file readability checks, providing clearer feedback on file access issues. - Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid. This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management. --- libs/platform/src/system-paths.ts | 37 ++++++++--- .../tests/oauth-credential-detection.test.ts | 61 +++++++++++++------ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index fb5e6bd3..f1749464 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1065,11 +1065,20 @@ export async function getClaudeAuthIndicators(): Promise { }; // Check settings file + // First check existence, then try to read to confirm it's actually readable try { if (await systemPathAccess(settingsPath)) { settingsFileCheck.exists = true; - settingsFileCheck.readable = true; - result.hasSettingsFile = true; + // Try to actually read the file to confirm read permissions + try { + await systemPathReadFile(settingsPath); + settingsFileCheck.readable = true; + result.hasSettingsFile = true; + } catch (readErr) { + // File exists but cannot be read (permission denied, etc.) + settingsFileCheck.readable = false; + settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`; + } } } catch (err) { settingsFileCheck.error = err instanceof Error ? err.message : String(err); @@ -1117,6 +1126,9 @@ export async function getClaudeAuthIndicators(): Promise { } // Check credentials files + // We iterate through all credential paths and only stop when we find a file + // that contains actual credentials (OAuth tokens or API keys). An empty or + // token-less file should not prevent checking subsequent credential paths. for (let i = 0; i < credentialPaths.length; i++) { const credPath = credentialPaths[i]; const credCheck = credentialFileChecks[i]; @@ -1126,18 +1138,27 @@ export async function getClaudeAuthIndicators(): Promise { credCheck.readable = true; try { const credentials = JSON.parse(content); - result.hasCredentialsFile = true; // Support multiple credential formats: // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } // 2. Legacy format: { oauth_token } or { access_token } // 3. API key format: { api_key } const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; + const hasOAuthToken = hasClaudeOauth || hasLegacyOauth; + const hasApiKey = !!credentials.api_key; + + // Only consider this a valid credentials file if it actually contains tokens + // An empty JSON file ({}) or file without tokens should not stop us from + // checking subsequent credential paths + if (hasOAuthToken || hasApiKey) { + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken, + hasApiKey, + }; + break; // Found valid credentials, stop searching + } + // File exists and is valid JSON but contains no tokens - continue checking other paths } catch (parseErr) { credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts index cf5a4705..6e445b22 100644 --- a/libs/platform/tests/oauth-credential-detection.test.ts +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -173,10 +173,14 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials).not.toBeNull(); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty credentials file ({}) should NOT be treated as having credentials + // because it contains no actual tokens. This allows the system to continue + // checking subsequent credential paths that might have valid tokens. + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + // But the file should still show as existing and readable in the checks + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].readable).toBe(true); }); it('should handle credentials file with null values', async () => { @@ -191,9 +195,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with all null values should NOT be treated as having credentials + // because null values are not valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle credentials with empty string values', async () => { @@ -210,10 +215,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - // Empty strings should not be treated as valid credentials - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty strings should NOT be treated as having credentials + // This allows checking subsequent credential paths for valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should detect settings file presence', async () => { @@ -337,6 +342,27 @@ describe('OAuth Credential Detection', () => { expect(indicators.credentials?.hasOAuthToken).toBe(true); expect(indicators.credentials?.hasApiKey).toBe(false); }); + + it('should check second credentials file if first file has no tokens', async () => { + // Write empty/token-less content to .credentials.json (first path checked) + // This tests the bug fix: previously, an empty JSON file would stop the search + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({})); + + // Write actual credentials to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key-from-second-file', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in second file since first file has no tokens + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); }); describe('getCodexAuthIndicators', () => { @@ -585,9 +611,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with unexpected structure but no valid tokens should NOT be treated as having credentials + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle array instead of object in credentials', async () => { @@ -598,10 +624,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - // Array is valid JSON but wrong structure - should handle gracefully - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle numeric values in credential fields', async () => { From 3b361cb0b918b9e37b43c5136af68b7d91921a60 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 5 Feb 2026 22:17:55 +0100 Subject: [PATCH 06/14] chore: update Codex SDK to version 0.98.0 and add GPT-5.3-Codex model - Upgraded @openai/codex-sdk from version 0.77.0 to 0.98.0 in package-lock.json and package.json. - Introduced new model 'GPT-5.3-Codex' with enhanced capabilities in codex-models.ts and related files. - Updated descriptions for existing models to reflect their latest features and improvements. - Adjusted Codex model configuration and display to include the new model and its attributes. These changes enhance the Codex model offerings and ensure compatibility with the latest SDK version. --- apps/server/package.json | 2 +- apps/server/src/providers/codex-models.ts | 28 +++++++++++++------ .../providers/codex-model-configuration.tsx | 14 +++++++--- libs/platform/src/system-paths.ts | 24 ++++++++++++++-- libs/types/src/codex-models.ts | 16 ++++++++--- libs/types/src/model-display.ts | 17 ++++++++--- libs/types/src/model.ts | 11 +++++--- package-lock.json | 8 +++--- 8 files changed, 88 insertions(+), 32 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index c9015aea..0b4deeac 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ "@automaker/utils": "1.0.0", "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", - "@openai/codex-sdk": "^0.77.0", + "@openai/codex-sdk": "^0.98.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", diff --git a/apps/server/src/providers/codex-models.ts b/apps/server/src/providers/codex-models.ts index 141d5355..7840888b 100644 --- a/apps/server/src/providers/codex-models.ts +++ b/apps/server/src/providers/codex-models.ts @@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000; export const CODEX_MODELS: ModelDefinition[] = [ // ========== Recommended Codex Models ========== { - id: CODEX_MODEL_MAP.gpt52Codex, - name: 'GPT-5.2-Codex', - modelString: CODEX_MODEL_MAP.gpt52Codex, + id: CODEX_MODEL_MAP.gpt53Codex, + name: 'GPT-5.3-Codex', + modelString: CODEX_MODEL_MAP.gpt53Codex, provider: 'openai', - description: - 'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).', + description: 'Latest frontier agentic coding model.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, @@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [ default: true, hasReasoning: true, }, + { + id: CODEX_MODEL_MAP.gpt52Codex, + name: 'GPT-5.2-Codex', + modelString: CODEX_MODEL_MAP.gpt52Codex, + provider: 'openai', + description: 'Frontier agentic coding model.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt51CodexMax, name: 'GPT-5.1-Codex-Max', modelString: CODEX_MODEL_MAP.gpt51CodexMax, provider: 'openai', - description: 'Optimized for long-horizon, agentic coding tasks in Codex.', + description: 'Codex-optimized flagship for deep and fast reasoning.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, @@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [ name: 'GPT-5.1-Codex-Mini', modelString: CODEX_MODEL_MAP.gpt51CodexMini, provider: 'openai', - description: 'Smaller, more cost-effective version for faster workflows.', + description: 'Optimized for codex. Cheaper, faster, but less capable.', contextWindow: CONTEXT_WINDOW_128K, maxOutputTokens: MAX_OUTPUT_16K, supportsVision: true, @@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [ name: 'GPT-5.2', modelString: CODEX_MODEL_MAP.gpt52, provider: 'openai', - description: 'Best general agentic model for tasks across industries and domains.', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding.', contextWindow: CONTEXT_WINDOW_256K, maxOutputTokens: MAX_OUTPUT_32K, supportsVision: true, diff --git a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx index a9d8c06e..de1d9555 100644 --- a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx @@ -27,25 +27,30 @@ interface CodexModelInfo { } const CODEX_MODEL_INFO: Record = { + 'codex-gpt-5.3-codex': { + id: 'codex-gpt-5.3-codex', + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model', + }, 'codex-gpt-5.2-codex': { id: 'codex-gpt-5.2-codex', label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering', + description: 'Frontier agentic coding model', }, 'codex-gpt-5.1-codex-max': { id: 'codex-gpt-5.1-codex-max', label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex', + description: 'Codex-optimized flagship for deep and fast reasoning', }, 'codex-gpt-5.1-codex-mini': { id: 'codex-gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows', + description: 'Optimized for codex. Cheaper, faster, but less capable', }, 'codex-gpt-5.2': { id: 'codex-gpt-5.2', label: 'GPT-5.2', - description: 'Best general agentic model for tasks across industries and domains', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding', }, 'codex-gpt-5.1': { id: 'codex-gpt-5.1', @@ -160,6 +165,7 @@ export function CodexModelConfiguration({ function supportsReasoningEffort(modelId: string): boolean { const reasoningModels = [ + 'codex-gpt-5.3-codex', 'codex-gpt-5.2-codex', 'codex-gpt-5.1-codex-max', 'codex-gpt-5.2', diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa..ce1246eb 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -54,13 +54,19 @@ export function getClaudeCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ path.join(os.homedir(), '.local', 'bin', 'claude.exe'), path.join(appData, 'npm', 'claude.cmd'), path.join(appData, 'npm', 'claude'), path.join(appData, '.npm-global', 'bin', 'claude.cmd'), path.join(appData, '.npm-global', 'bin', 'claude'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'claude.cmd'), path.join(nvmSymlink, 'claude')); + } + return paths; } return [ @@ -130,7 +136,8 @@ export function getCodexCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ path.join(homeDir, '.local', 'bin', 'codex.exe'), path.join(appData, 'npm', 'codex.cmd'), path.join(appData, 'npm', 'codex'), @@ -142,6 +149,11 @@ export function getCodexCliPaths(): string[] { path.join(localAppData, 'pnpm', 'codex.cmd'), path.join(localAppData, 'pnpm', 'codex'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'codex.cmd'), path.join(nvmSymlink, 'codex')); + } + return paths; } // Include NVM bin paths for codex installed via npm global under NVM @@ -1126,7 +1138,8 @@ export function getOpenCodeCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); - return [ + const nvmSymlink = process.env.NVM_SYMLINK; + const paths = [ // OpenCode's default installation directory path.join(homeDir, '.opencode', 'bin', 'opencode.exe'), path.join(homeDir, '.local', 'bin', 'opencode.exe'), @@ -1143,6 +1156,11 @@ export function getOpenCodeCliPaths(): string[] { path.join(homeDir, 'go', 'bin', 'opencode.exe'), path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'), ]; + // nvm4w (NVM for Windows) symlink path + if (nvmSymlink) { + paths.push(path.join(nvmSymlink, 'opencode.cmd'), path.join(nvmSymlink, 'opencode')); + } + return paths; } // Include NVM bin paths for opencode installed via npm global under NVM diff --git a/libs/types/src/codex-models.ts b/libs/types/src/codex-models.ts index cf4db0ea..934218ea 100644 --- a/libs/types/src/codex-models.ts +++ b/libs/types/src/codex-models.ts @@ -6,6 +6,7 @@ * IMPORTANT: All Codex models use 'codex-' prefix to distinguish from Cursor CLI models */ export type CodexModelId = + | 'codex-gpt-5.3-codex' | 'codex-gpt-5.2-codex' | 'codex-gpt-5.1-codex-max' | 'codex-gpt-5.1-codex-mini' @@ -29,31 +30,38 @@ export interface CodexModelConfig { * All keys use 'codex-' prefix to distinguish from Cursor CLI models */ export const CODEX_MODEL_CONFIG_MAP: Record = { + 'codex-gpt-5.3-codex': { + id: 'codex-gpt-5.3-codex', + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model', + hasThinking: true, + supportsVision: true, + }, 'codex-gpt-5.2-codex': { id: 'codex-gpt-5.2-codex', label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering', + description: 'Frontier agentic coding model', hasThinking: true, supportsVision: true, }, 'codex-gpt-5.1-codex-max': { id: 'codex-gpt-5.1-codex-max', label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex', + description: 'Codex-optimized flagship for deep and fast reasoning', hasThinking: true, supportsVision: true, }, 'codex-gpt-5.1-codex-mini': { id: 'codex-gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows', + description: 'Optimized for codex. Cheaper, faster, but less capable', hasThinking: false, supportsVision: true, }, 'codex-gpt-5.2': { id: 'codex-gpt-5.2', label: 'GPT-5.2 (Codex)', - description: 'Best general agentic model for tasks across industries and domains via Codex', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding', hasThinking: true, supportsVision: true, }, diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index 28670328..08eaf208 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -72,10 +72,18 @@ export const CLAUDE_MODELS: ModelOption[] = [ * Official models from https://developers.openai.com/codex/models/ */ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ + { + id: CODEX_MODEL_MAP.gpt53Codex, + label: 'GPT-5.3-Codex', + description: 'Latest frontier agentic coding model.', + badge: 'Premium', + provider: 'codex', + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt52Codex, label: 'GPT-5.2-Codex', - description: 'Most advanced agentic coding model for complex software engineering.', + description: 'Frontier agentic coding model.', badge: 'Premium', provider: 'codex', hasReasoning: true, @@ -83,7 +91,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt51CodexMax, label: 'GPT-5.1-Codex-Max', - description: 'Optimized for long-horizon, agentic coding tasks in Codex.', + description: 'Codex-optimized flagship for deep and fast reasoning.', badge: 'Premium', provider: 'codex', hasReasoning: true, @@ -91,7 +99,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt51CodexMini, label: 'GPT-5.1-Codex-Mini', - description: 'Smaller, more cost-effective version for faster workflows.', + description: 'Optimized for codex. Cheaper, faster, but less capable.', badge: 'Speed', provider: 'codex', hasReasoning: false, @@ -99,7 +107,7 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ { id: CODEX_MODEL_MAP.gpt52, label: 'GPT-5.2', - description: 'Best general agentic model for tasks across industries and domains.', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding.', badge: 'Balanced', provider: 'codex', hasReasoning: true, @@ -211,6 +219,7 @@ export function getModelDisplayName(model: ModelAlias | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + [CODEX_MODEL_MAP.gpt53Codex]: 'GPT-5.3-Codex', [CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex', [CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max', [CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini', diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 5538989e..b6b90da9 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -50,15 +50,17 @@ export const LEGACY_CLAUDE_ALIAS_MAP: Record = { */ export const CODEX_MODEL_MAP = { // Recommended Codex-specific models - /** Most advanced agentic coding model for complex software engineering (default for ChatGPT users) */ + /** Latest frontier agentic coding model */ + gpt53Codex: 'codex-gpt-5.3-codex', + /** Frontier agentic coding model */ gpt52Codex: 'codex-gpt-5.2-codex', - /** Optimized for long-horizon, agentic coding tasks in Codex */ + /** Codex-optimized flagship for deep and fast reasoning */ gpt51CodexMax: 'codex-gpt-5.1-codex-max', - /** Smaller, more cost-effective version for faster workflows */ + /** Optimized for codex. Cheaper, faster, but less capable */ gpt51CodexMini: 'codex-gpt-5.1-codex-mini', // General-purpose GPT models (also available in Codex) - /** Best general agentic model for tasks across industries and domains */ + /** Latest frontier model with improvements across knowledge, reasoning and coding */ gpt52: 'codex-gpt-5.2', /** Great for coding and agentic tasks across domains */ gpt51: 'codex-gpt-5.1', @@ -71,6 +73,7 @@ export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP); * These models can use reasoning.effort parameter */ export const REASONING_CAPABLE_MODELS = new Set([ + CODEX_MODEL_MAP.gpt53Codex, CODEX_MODEL_MAP.gpt52Codex, CODEX_MODEL_MAP.gpt51CodexMax, CODEX_MODEL_MAP.gpt52, diff --git a/package-lock.json b/package-lock.json index 9f4f4d28..0649982d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "@automaker/utils": "1.0.0", "@github/copilot-sdk": "^0.1.16", "@modelcontextprotocol/sdk": "1.25.2", - "@openai/codex-sdk": "^0.77.0", + "@openai/codex-sdk": "^0.98.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", @@ -3949,9 +3949,9 @@ } }, "node_modules/@openai/codex-sdk": { - "version": "0.77.0", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.77.0.tgz", - "integrity": "sha512-bvJQ4dASnZ7jgfxmseViQwdRupHxs0TwHSZFeYB0gpdOAXnWwDWdGJRCMyphLSHwExRp27JNOk7EBFVmZRBanQ==", + "version": "0.98.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.98.0.tgz", + "integrity": "sha512-TbPgrBpuSNMJyOXys0HNsh6UoP5VIHu1fVh2KDdACi5XyB0vuPtzBZC+qOsxHz7WXEQPFlomPLyxS6JnE5Okmg==", "license": "Apache-2.0", "engines": { "node": ">=18" From 835ffe31853dde25d3676ca6dd4a100268f39570 Mon Sep 17 00:00:00 2001 From: Kacper Date: Thu, 5 Feb 2026 22:43:22 +0100 Subject: [PATCH 07/14] feat: update Claude model to Opus 4.6 and enhance adaptive thinking support - Changed model identifier from `claude-opus-4-5-20251101` to `claude-opus-4-6` across various files, including documentation and code references. - Updated the SDK to support adaptive thinking for Opus 4.6, allowing the model to determine its own reasoning depth. - Enhanced the thinking level options to include 'adaptive' and adjusted related components to reflect this change. - Updated tests to ensure compatibility with the new model and its features. These changes improve the model's capabilities and user experience by leveraging adaptive reasoning. --- CLAUDE.md | 2 +- apps/server/package.json | 2 +- apps/server/src/lib/sdk-options.ts | 18 ++++++++++- apps/server/src/providers/claude-provider.ts | 17 ++++++---- apps/server/src/providers/provider-factory.ts | 2 +- .../tests/unit/lib/model-resolver.test.ts | 4 +-- .../server/tests/unit/lib/sdk-options.test.ts | 24 ++++++++++++++ .../unit/providers/claude-provider.test.ts | 32 +++++++++---------- .../unit/providers/provider-factory.test.ts | 6 ++-- apps/ui/docs/AGENT_ARCHITECTURE.md | 2 +- .../board-view/dialogs/add-feature-dialog.tsx | 16 +++++++++- .../board-view/shared/model-constants.ts | 10 +++++- .../shared/thinking-level-selector.tsx | 14 ++++++-- .../model-defaults/phase-model-selector.tsx | 18 ++++++++--- apps/ui/src/lib/agent-context-parser.ts | 3 +- docs/llm-shared-packages.md | 2 +- docs/server/providers.md | 6 ++-- docs/server/utilities.md | 12 +++---- libs/model-resolver/README.md | 12 +++---- libs/model-resolver/tests/resolver.test.ts | 4 +-- libs/types/src/index.ts | 2 ++ libs/types/src/model-display.ts | 2 ++ libs/types/src/model.ts | 6 ++-- libs/types/src/settings.ts | 23 ++++++++++++- package-lock.json | 10 +++--- 25 files changed, 178 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 128cd8d7..84dd1fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali - `haiku` → `claude-haiku-4-5` - `sonnet` → `claude-sonnet-4-20250514` -- `opus` → `claude-opus-4-5-20251101` +- `opus` → `claude-opus-4-6` ## Environment Variables diff --git a/apps/server/package.json b/apps/server/package.json index 0b4deeac..ed005c54 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,7 +24,7 @@ "test:unit": "vitest run tests/unit" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.1.76", + "@anthropic-ai/claude-agent-sdk": "0.2.32", "@automaker/dependency-resolver": "1.0.0", "@automaker/git-utils": "1.0.0", "@automaker/model-resolver": "1.0.0", diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index cc1df2f5..674350a5 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions { /** * Build thinking options for SDK configuration. * Converts ThinkingLevel to maxThinkingTokens for the Claude SDK. + * For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model + * decide its own reasoning depth. * * @param thinkingLevel - The thinking level to convert - * @returns Object with maxThinkingTokens if thinking is enabled + * @returns Object with maxThinkingTokens if thinking is enabled with a budget */ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial { + if (!thinkingLevel || thinkingLevel === 'none') { + return {}; + } + + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens + // The model will use adaptive thinking by default + if (thinkingLevel === 'adaptive') { + logger.debug( + `buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)` + ); + return {}; + } + + // Manual budget-based thinking for Haiku/Sonnet const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); logger.debug( `buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}` diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index cfb59093..78a0a0c7 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider { // claudeCompatibleProvider takes precedence over claudeApiProfile const providerConfig = claudeCompatibleProvider || claudeApiProfile; - // Convert thinking level to token budget - const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); + // Build thinking configuration + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default + // Manual thinking (Haiku/Sonnet): use budget_tokens + const maxThinkingTokens = + thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel); // Build Claude SDK options const sdkOptions: Options = { @@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider { getAvailableModels(): ModelDefinition[] { const models = [ { - id: 'claude-opus-4-5-20251101', - name: 'Claude Opus 4.5', - modelString: 'claude-opus-4-5-20251101', + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + modelString: 'claude-opus-4-6', provider: 'anthropic', - description: 'Most capable Claude model', + description: 'Most capable Claude model with adaptive thinking', contextWindow: 200000, - maxOutputTokens: 16000, + maxOutputTokens: 128000, supportsVision: true, supportsTools: true, tier: 'premium' as const, diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 1e91760f..a6dff69e 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -103,7 +103,7 @@ export class ProviderFactory { /** * Get the appropriate provider for a given model ID * - * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto") + * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto") * @param options Optional settings * @param options.throwOnDisconnected Throw error if provider is disconnected (default: true) * @returns Provider instance for the model diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c1bff78d..65e3115d 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -35,7 +35,7 @@ describe('model-resolver.ts', () => { it("should resolve 'opus' alias to full model string", () => { const result = resolveModelString('opus'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); expect(consoleSpy.log).toHaveBeenCalledWith( expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); @@ -117,7 +117,7 @@ describe('model-resolver.ts', () => { describe('getEffectiveModel', () => { it('should prioritize explicit model over session and default', () => { const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); }); it('should use session model when explicit is not provided', () => { diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 029cd8fa..69d69794 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -491,5 +491,29 @@ describe('sdk-options.ts', () => { expect(options.maxThinkingTokens).toBeUndefined(); }); }); + + describe('adaptive thinking for Opus 4.6', () => { + it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'adaptive', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); }); }); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index c3f83f8f..7df211ef 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -39,7 +39,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Hello', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -59,7 +59,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test prompt', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test/dir', systemPrompt: 'You are helpful', maxTurns: 10, @@ -71,7 +71,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test prompt', options: expect.objectContaining({ - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', systemPrompt: 'You are helpful', maxTurns: 10, cwd: '/test/dir', @@ -91,7 +91,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -116,7 +116,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', abortController, }); @@ -145,7 +145,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Current message', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', @@ -176,7 +176,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: arrayPrompt as any, - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -196,7 +196,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -222,7 +222,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -286,7 +286,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -313,7 +313,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -341,7 +341,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -366,12 +366,12 @@ describe('claude-provider.ts', () => { expect(models).toHaveLength(4); }); - it('should include Claude Opus 4.5', () => { + it('should include Claude Opus 4.6', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5'); + expect(opus?.name).toBe('Claude Opus 4.6'); expect(opus?.provider).toBe('anthropic'); }); @@ -400,7 +400,7 @@ describe('claude-provider.ts', () => { it('should mark Opus as default', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus?.default).toBe(true); }); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index fbf01e90..b9aef928 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -54,8 +54,8 @@ describe('provider-factory.ts', () => { describe('getProviderForModel', () => { describe('Claude models (claude-* prefix)', () => { - it('should return ClaudeProvider for claude-opus-4-5-20251101', () => { - const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); + it('should return ClaudeProvider for claude-opus-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); @@ -70,7 +70,7 @@ describe('provider-factory.ts', () => { }); it('should be case-insensitive for claude models', () => { - const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101'); + const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); }); diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md index 4c9f0d11..f5c374c4 100644 --- a/apps/ui/docs/AGENT_ARCHITECTURE.md +++ b/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -199,7 +199,7 @@ The agent is configured with: ```javascript { - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", maxTurns: 20, cwd: workingDirectory, allowedTools: [ diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index b8dd8776..2dbf0808 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -264,7 +264,21 @@ export function AddFeatureDialog({ }, [planningMode]); const handleModelChange = (entry: PhaseModelEntry) => { - setModelEntry(entry); + // Normalize thinking level when switching between adaptive and non-adaptive models + const isNewModelAdaptive = + entry.model === 'claude-opus' || + (typeof entry.model === 'string' && entry.model.includes('opus-4-6')); + const currentLevel = entry.thinkingLevel || 'none'; + + if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') { + // Switching TO Opus 4.6 with a manual level -> auto-switch to 'adaptive' + setModelEntry({ ...entry, thinkingLevel: 'adaptive' }); + } else if (!isNewModelAdaptive && currentLevel === 'adaptive') { + // Switching FROM Opus 4.6 with adaptive -> auto-switch to 'high' + setModelEntry({ ...entry, thinkingLevel: 'high' }); + } else { + setModelEntry(entry); + } }; const buildFeatureData = (): FeatureData | null => { diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index c56ad46a..2816e556 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -167,7 +167,14 @@ export const ALL_MODELS: ModelOption[] = [ ...COPILOT_MODELS, ]; -export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; +export const THINKING_LEVELS: ThinkingLevel[] = [ + 'none', + 'low', + 'medium', + 'high', + 'ultrathink', + 'adaptive', +]; export const THINKING_LEVEL_LABELS: Record = { none: 'None', @@ -175,6 +182,7 @@ export const THINKING_LEVEL_LABELS: Record = { medium: 'Med', high: 'High', ultrathink: 'Ultra', + adaptive: 'Adaptive', }; /** diff --git a/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx b/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx index 74b791a3..3e111a31 100644 --- a/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/thinking-level-selector.tsx @@ -2,19 +2,25 @@ import { Label } from '@/components/ui/label'; import { Brain } from 'lucide-react'; import { cn } from '@/lib/utils'; import { ThinkingLevel } from '@/store/app-store'; -import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants'; +import { THINKING_LEVEL_LABELS } from './model-constants'; +import { getThinkingLevelsForModel } from '@automaker/types'; interface ThinkingLevelSelectorProps { selectedLevel: ThinkingLevel; onLevelSelect: (level: ThinkingLevel) => void; testIdPrefix?: string; + /** Optional model ID to filter available thinking levels (e.g., Opus 4.6 only shows None/Adaptive) */ + model?: string; } export function ThinkingLevelSelector({ selectedLevel, onLevelSelect, testIdPrefix = 'thinking-level', + model, }: ThinkingLevelSelectorProps) { + const levels = model ? getThinkingLevelsForModel(model) : getThinkingLevelsForModel(''); + return (
- {THINKING_LEVELS.map((level) => ( + {levels.map((level) => (
); diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 20420388..25424fa6 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -21,6 +21,7 @@ import { isGroupSelected, getSelectedVariant, codexModelHasThinking, + getThinkingLevelsForModel, } from '@automaker/types'; import { CLAUDE_MODELS, @@ -28,7 +29,6 @@ import { OPENCODE_MODELS, GEMINI_MODELS, COPILOT_MODELS, - THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, @@ -1296,7 +1296,9 @@ export function PhaseModelSelector({
Thinking Level
- {THINKING_LEVELS.map((level) => ( + {getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : '' + ).map((level) => (
{isSelected && currentThinking === level && ( @@ -1402,7 +1405,9 @@ export function PhaseModelSelector({
Thinking Level
- {THINKING_LEVELS.map((level) => ( + {getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : '' + ).map((level) => (