diff --git a/apps/agor-daemon/src/hooks/classify-missing-credential.test.ts b/apps/agor-daemon/src/hooks/classify-missing-credential.test.ts new file mode 100644 index 0000000000..16ab2402cf --- /dev/null +++ b/apps/agor-daemon/src/hooks/classify-missing-credential.test.ts @@ -0,0 +1,278 @@ +/** + * Classification is driven by `resolveApiKey` (plus a native-auth probe), never + * by error text, and only fires on messages flagged as a failure or zero-turn. + */ + +import { resolveApiKey } from '@agor/core/config'; +import type { SessionRepository, TaskRepository } from '@agor/core/db'; +import type { HookContext, Message, Session, Task } from '@agor/core/types'; +import { MessageRole } from '@agor/core/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { classifyMissingCredentialFailure } from './classify-missing-credential'; + +vi.mock('@agor/core/config', () => ({ + resolveApiKey: vi.fn(), +})); + +const TOOL_DISPLAY_NAMES = { 'claude-code': 'Claude Code', opencode: 'OpenCode' }; + +function makeContext(data: Partial | undefined): HookContext { + return { data } as unknown as HookContext; +} + +function makeTask(overrides: Partial = {}): Task { + return { task_id: 'task-1', session_id: 'session-1', created_by: 'user-1', ...overrides } as Task; +} + +function makeSession(overrides: Partial = {}): Session { + return { session_id: 'session-1', agentic_tool: 'claude-code', ...overrides } as Session; +} + +describe('classifyMissingCredentialFailure', () => { + let taskRepository: Pick; + let sessionsRepository: Pick; + let probeNativeAuth: (tool: string) => Promise; + + beforeEach(() => { + vi.mocked(resolveApiKey).mockReset(); + taskRepository = { findById: vi.fn().mockResolvedValue(makeTask()) }; + sessionsRepository = { findById: vi.fn().mockResolvedValue(makeSession()) }; + // Default: native auth is NOT working, so no-key resolution stays classified. + probeNativeAuth = vi.fn().mockResolvedValue(false); + }); + + function runHook() { + return classifyMissingCredentialFailure( + {} as never, + taskRepository, + sessionsRepository, + TOOL_DISPLAY_NAMES, + probeNativeAuth + ); + } + + const failureMessage: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + content: 'Claude SDK error after 3 messages: Not logged in · Please run /login', + metadata: { is_task_failure: true }, + }; + + it('classifies as missing_credential when no credential resolves anywhere', async () => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: undefined, + source: 'none', + useNativeAuth: true, + }); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBe('missing_credential'); + expect((ctx.data as Message).metadata?.tool).toBe('claude-code'); + // Raw stderr text must not survive into the persisted content. + expect((ctx.data as Message).content).not.toContain('/login'); + }); + + describe('native-auth tools (no stored key, resolveApiKey returns useNativeAuth)', () => { + beforeEach(() => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: undefined, + source: 'none', + useNativeAuth: true, + }); + }); + + it('does NOT classify when the native CLI/OAuth probe reports authenticated', async () => { + probeNativeAuth = vi.fn().mockResolvedValue(true); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect((ctx.data as Message).content).toBe(failureMessage.content); + expect(probeNativeAuth).toHaveBeenCalledWith('claude-code'); + }); + + it('classifies when the native CLI/OAuth probe reports NOT authenticated', async () => { + probeNativeAuth = vi.fn().mockResolvedValue(false); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBe('missing_credential'); + expect(probeNativeAuth).toHaveBeenCalledWith('claude-code'); + }); + }); + + describe('zero-turn "success" pathway (claude CLI reports success but never called the model)', () => { + // A subtype:'success' result with zero real assistant turns: the executor + // synthesizes the result text into an assistant message stamped with + // `is_zero_turn_result` (no `is_task_failure` marker on this pathway). + const zeroTurnAssistantMessage: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + type: 'assistant', + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Not logged in · Please run /login' }], + metadata: { is_zero_turn_result: true }, + }; + + it('classifies a zero-turn assistant message when no credential resolves anywhere', async () => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: undefined, + source: 'none', + useNativeAuth: true, + }); + + const ctx = await runHook()(makeContext({ ...zeroTurnAssistantMessage })); + const result = ctx.data as Message; + + expect(result.metadata?.error_kind).toBe('missing_credential'); + expect(result.metadata?.tool).toBe('claude-code'); + // Normalized onto system/SYSTEM so the UI has one render branch + // regardless of which SDK pathway produced the classification. + expect(result.type).toBe('system'); + expect(result.role).toBe(MessageRole.SYSTEM); + expect(JSON.stringify(result.content)).not.toContain('/login'); + }); + + it('does not classify (and does not touch) a real assistant reply with no zero-turn marker', async () => { + const realReply: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + type: 'assistant', + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Here is the answer.' }], + metadata: { tokens: { input: 512, output: 24 } }, + }; + + const ctx = await runHook()(makeContext({ ...realReply })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect((ctx.data as Message).type).toBe('assistant'); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + + it('does not reclassify legitimate zero-turn output (e.g. /cost) when the user IS authenticated', async () => { + // A local slash-command result (e.g. /cost) is also a zero-turn success, + // so it carries the same marker as an auth failure. resolveApiKey is the + // arbiter: a real credential means the output is left untouched. + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: 'sk-ant-user-key', + source: 'user', + useNativeAuth: false, + }); + + const localCommandOutput: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + type: 'assistant', + role: MessageRole.ASSISTANT, + content: [{ type: 'text', text: 'Total cost: $0.42' }], + metadata: { is_zero_turn_result: true }, + }; + + const ctx = await runHook()(makeContext({ ...localCommandOutput })); + const result = ctx.data as Message; + + expect(result.metadata?.error_kind).toBeUndefined(); + expect(result.type).toBe('assistant'); + expect(result.role).toBe(MessageRole.ASSISTANT); + expect(JSON.stringify(result.content)).toContain('Total cost'); + }); + + it('does not trigger for messages lacking any zero-turn / failure marker', async () => { + const userMessage: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + type: 'user', + role: MessageRole.USER, + content: 'hello', + metadata: {}, + }; + + const ctx = await runHook()(makeContext({ ...userMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + }); + + it('does not classify when a workspace/env-level credential resolves', async () => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: 'sk-ant-env-key', + source: 'env', + useNativeAuth: false, + }); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect((ctx.data as Message).content).toBe(failureMessage.content); + }); + + it('does not classify when a per-user credential resolves', async () => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: 'sk-ant-user-key', + source: 'user', + useNativeAuth: false, + }); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + }); + + it('does not classify unrelated messages (no is_task_failure marker)', async () => { + vi.mocked(resolveApiKey).mockResolvedValue({ + apiKey: undefined, + source: 'none', + useNativeAuth: true, + }); + + const rateLimitMessage: Partial = { + task_id: 'task-1' as Message['task_id'], + session_id: 'session-1' as Message['session_id'], + content: 'Rate limited, retrying...', + metadata: {}, + }; + + const ctx = await runHook()(makeContext({ ...rateLimitMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + + it('is a no-op when context.data is undefined', async () => { + const ctx = await runHook()(makeContext(undefined)); + expect(ctx.data).toBeUndefined(); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + + it('falls through untouched for tools with no mapped API key (e.g. opencode)', async () => { + sessionsRepository.findById = vi + .fn() + .mockResolvedValue(makeSession({ agentic_tool: 'opencode' })); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + + it('falls through untouched when the task or session cannot be found', async () => { + taskRepository.findById = vi.fn().mockResolvedValue(null); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect(resolveApiKey).not.toHaveBeenCalled(); + }); + + it('swallows classification errors and leaves the message untouched', async () => { + vi.mocked(resolveApiKey).mockRejectedValue(new Error('boom')); + + const ctx = await runHook()(makeContext({ ...failureMessage })); + + expect((ctx.data as Message).metadata?.error_kind).toBeUndefined(); + expect((ctx.data as Message).content).toBe(failureMessage.content); + }); +}); diff --git a/apps/agor-daemon/src/hooks/classify-missing-credential.ts b/apps/agor-daemon/src/hooks/classify-missing-credential.ts new file mode 100644 index 0000000000..a4f601aedb --- /dev/null +++ b/apps/agor-daemon/src/hooks/classify-missing-credential.ts @@ -0,0 +1,80 @@ +/** + * Before-create hook that reclassifies a task failure as "missing credential" + * via `resolveApiKey`, never by matching the provider's raw stderr. Fires on + * two failure shapes — a thrown error (`is_task_failure`) and a zero-turn + * "success" whose text is the auth error (`is_zero_turn_result`) — then lets + * the resolve result (plus a native-auth probe) decide the outcome. + */ + +import { resolveApiKey } from '@agor/core/config'; +import type { SessionRepository, TaskRepository, TenantScopeAwareDatabase } from '@agor/core/db'; +import type { HookContext, Message, TaskID, UserID } from '@agor/core/types'; +import { MessageRole, TOOL_API_KEY_NAMES } from '@agor/core/types'; + +/** Fallback for consumers that render `content` raw (mobile, gateway, CLI). + * The web UI renders its own copy from MissingCredentialPanel instead. */ +function fallbackContent(toolDisplayName: string): string { + return `This session needs to be connected to ${toolDisplayName} before it can run.`; +} + +export function classifyMissingCredentialFailure( + db: TenantScopeAwareDatabase, + taskRepository: Pick, + sessionsRepository: Pick, + toolDisplayNames: Record, + // Injected (not imported) so the hook stays free of the Claude SDK's import + // graph, which breaks under vitest's ESM resolution. + probeNativeAuth?: (tool: string) => Promise +) { + return async (context: HookContext): Promise => { + const data = context.data as Partial | undefined; + if (!data?.task_id || !data.session_id) return context; + + const isThrownFailureNotice = data.metadata?.is_task_failure === true; + const isZeroTurnResult = data.metadata?.is_zero_turn_result === true; + + if (!isThrownFailureNotice && !isZeroTurnResult) return context; + + try { + const [task, session] = await Promise.all([ + taskRepository.findById(data.task_id as TaskID), + sessionsRepository.findById(data.session_id), + ]); + if (!task || !session) return context; + + const tool = session.agentic_tool; + const keyName = TOOL_API_KEY_NAMES[tool]; + // Tools with no mapped key (e.g. opencode) aren't credential-gated. + if (!keyName) return context; + + const { apiKey, useNativeAuth } = await resolveApiKey(keyName, { + userId: task.created_by as UserID, + db, + tool, + }); + if (apiKey) return context; // A credential DID resolve — some other failure. + + // Native-auth tools resolve to no key even when logged in via CLI/OAuth; + // probe the live auth state before concluding it's actually missing. + if (useNativeAuth && probeNativeAuth && (await probeNativeAuth(tool))) return context; + + context.data = { + ...data, + // Normalize both pathways onto system/SYSTEM so the UI has one render branch. + type: 'system', + role: MessageRole.SYSTEM, + content: fallbackContent(toolDisplayNames[tool] ?? tool), + content_preview: fallbackContent(toolDisplayNames[tool] ?? tool).substring(0, 200), + metadata: { + ...data.metadata, + error_kind: 'missing_credential', + tool, + }, + }; + } catch (err) { + console.error('[classifyMissingCredentialFailure] classification failed:', err); + } + + return context; + }; +} diff --git a/apps/agor-daemon/src/register-hooks.ts b/apps/agor-daemon/src/register-hooks.ts index 6ebcfb7a88..3b40b211ad 100755 --- a/apps/agor-daemon/src/register-hooks.ts +++ b/apps/agor-daemon/src/register-hooks.ts @@ -27,6 +27,7 @@ import { ScheduleRepository, type SessionRepository, shortId, + TaskRepository, type TenantScopeAwareDatabase, UserMCPOAuthTokenRepository, type UsersRepository, @@ -67,6 +68,7 @@ import type { UserID, } from '@agor/core/types'; import { + AGENTIC_TOOL_DISPLAY_NAMES, GATEWAY_REDACTED_SENTINEL, GATEWAY_SENSITIVE_CONFIG_FIELDS, hasMinimumRole, @@ -78,6 +80,7 @@ import type { MessagesServiceImpl, SessionsServiceImpl, } from './declarations.js'; +import { classifyMissingCredentialFailure } from './hooks/classify-missing-credential.js'; import { gatewayRouteHook } from './hooks/gateway-route.js'; import { resolveForUserIdWithGate } from './oauth-auth-helpers.js'; import type { ArtifactsService } from './services/artifacts.js'; @@ -459,6 +462,10 @@ export function registerHooks(ctx: RegisterHooksContext): void { sessionsRepository, } = ctx; + // Used by classifyMissingCredentialFailure to look up the acting user for + // a failed task (no service-layer equivalent already in ctx). + const taskRepository = new TaskRepository(db); + // Helper: safely get a service (returns undefined if not registered due to tier=off) const safeService = (path: string) => { try { @@ -750,6 +757,18 @@ export function registerHooks(ctx: RegisterHooksContext): void { ensureCanPromptInSession(superadminOpts), // Require 'prompt' (or 'session' for own sessions) ] : []), + // Detect "no credential resolved for this session's provider" task + // failures structurally (reusing resolveApiKey's resolution order), + // never by matching the raw stderr-passthrough error text. Drives + // the Connect-AI empty state instead of a raw "/login" message. + classifyMissingCredentialFailure( + db, + taskRepository, + sessionsRepository, + AGENTIC_TOOL_DISPLAY_NAMES, + // Lazy dynamic import keeps the Claude SDK out of this module's static graph. + (tool) => import('./services/native-auth-probe.js').then((m) => m.checkNativeAuth(tool)) + ), ], patch: [ requireMinimumRole(ROLES.MEMBER, 'update messages'), diff --git a/apps/agor-daemon/src/services/check-auth-helpers.test.ts b/apps/agor-daemon/src/services/check-auth-helpers.test.ts new file mode 100644 index 0000000000..921e573511 --- /dev/null +++ b/apps/agor-daemon/src/services/check-auth-helpers.test.ts @@ -0,0 +1,27 @@ +/** Regression coverage for the `'none'` sentinel that a plain truthy check + * would misread as authenticated. */ + +import { describe, expect, it } from 'vitest'; +import { isRealAuthSource } from './check-auth-helpers'; + +describe('isRealAuthSource', () => { + it('treats the literal "none" sentinel as no signal', () => { + expect(isRealAuthSource('none')).toBe(false); + }); + + it('is case-insensitive for the sentinel', () => { + expect(isRealAuthSource('None')).toBe(false); + expect(isRealAuthSource('NONE')).toBe(false); + }); + + it('treats undefined and empty string as no signal', () => { + expect(isRealAuthSource(undefined)).toBe(false); + expect(isRealAuthSource('')).toBe(false); + }); + + it('treats a real source value as a signal', () => { + expect(isRealAuthSource('oauth')).toBe(true); + expect(isRealAuthSource('api-key')).toBe(true); + expect(isRealAuthSource('anthropic-console')).toBe(true); + }); +}); diff --git a/apps/agor-daemon/src/services/check-auth-helpers.ts b/apps/agor-daemon/src/services/check-auth-helpers.ts new file mode 100644 index 0000000000..224144e4b0 --- /dev/null +++ b/apps/agor-daemon/src/services/check-auth-helpers.ts @@ -0,0 +1,12 @@ +/** + * Pure helpers split out of `check-auth.ts` so they're unit-testable without + * its Claude SDK import graph (which breaks under vitest's ESM resolution). + */ + +/** + * Whether an `AccountInfo` source field indicates a real credential. The SDK + * signals "no source" with the literal `'none'`, so a truthy check false-positives. + */ +export function isRealAuthSource(value: string | undefined): boolean { + return !!value && value.toLowerCase() !== 'none'; +} diff --git a/apps/agor-daemon/src/services/check-auth.ts b/apps/agor-daemon/src/services/check-auth.ts index bde46a88e9..7b942d19fc 100644 --- a/apps/agor-daemon/src/services/check-auth.ts +++ b/apps/agor-daemon/src/services/check-auth.ts @@ -24,13 +24,8 @@ * the same user/tool env resolver used for session spawn. */ -import { promises as fs } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; import { resolveApiKey, resolveUserEnvironment } from '@agor/core/config'; import type { TenantScopeAwareDatabase } from '@agor/core/db'; -import type { SDKUserMessage } from '@agor/core/sdk'; -import { Claude } from '@agor/core/sdk'; import type { AgenticToolName, AuthCheckResult, @@ -38,14 +33,13 @@ import type { UserID, } from '@agor/core/types'; import { TOOL_API_KEY_NAMES } from '@agor/core/types'; +import { isRealAuthSource } from './check-auth-helpers.js'; +import { probeClaudeCodeAuth, probeCodexAuth } from './native-auth-probe.js'; /** Tools where no API key is required — native CLI/OAuth auth is a real, usable path. */ const NATIVE_AUTH_TOOLS = new Set(['claude-code', 'codex']); const FETCH_TIMEOUT_MS = 8_000; -const SDK_AUTH_PROBE_TIMEOUT_MS = 10_000; -// Codex treats the OAuth session as stale after ~8 days (per OpenAI docs). -const CODEX_SESSION_STALE_MS = 8 * 24 * 60 * 60 * 1000; async function withTimeout(promise: Promise, ms: number, message: string): Promise { let timer: NodeJS.Timeout | undefined; @@ -61,131 +55,6 @@ async function withTimeout(promise: Promise, ms: number, message: string): } } -/** - * Verify Claude Code auth by spawning the SDK in streaming-input mode and - * reading `accountInfo()` from its init handshake. The SDK launches the - * `claude` CLI in the same context the executor will at session-start, so a - * successful probe means real sessions will resolve creds the same way. - * - * We pass an AsyncIterable that yields nothing — control requests like - * `accountInfo()` require streaming-input mode, but never yielding means no - * user message is sent and no API call is made. Cleanup releases the held - * iterable and closes the query so the subprocess exits. - * - * Returns null on any failure (CLI missing, no auth, timeout, etc.). - */ -async function probeClaudeCodeAuth( - env?: Record -): Promise { - let releaseHeldInput!: () => void; - const heldInputPromise = new Promise((resolve) => { - releaseHeldInput = resolve; - }); - - // biome-ignore lint/correctness/useYield: intentional — holds the input stream open so the SDK enters streaming-input mode and accepts control requests like accountInfo(), but never sends a user message. - async function* neverYields(): AsyncIterable { - await heldInputPromise; - } - - const q = Claude.query({ - prompt: neverYields(), - options: env ? { env } : {}, - }); - - try { - const account = await Promise.race([ - q.accountInfo(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Auth probe timed out')), SDK_AUTH_PROBE_TIMEOUT_MS) - ), - ]); - return account ?? null; - } catch { - return null; - } finally { - releaseHeldInput(); - try { - q.close(); - } catch { - // best-effort cleanup - } - } -} - -/** - * Shape of `$CODEX_HOME/auth.json` — the file the codex CLI writes after a - * successful login. The executor reads from the same path (it explicitly - * does NOT override CODEX_HOME), so this is the authoritative signal for - * "will a Codex session start without a 'not logged in' error?" - */ -interface CodexAuthFile { - auth_mode?: string; - tokens?: { - access_token?: string; - refresh_token?: string; - id_token?: string; - }; - last_refresh?: string; - OPENAI_API_KEY?: string; -} - -type CodexAuthProbeResult = { - authenticated: boolean; - method: AuthCheckResult['method']; - hint?: string; -}; - -/** - * Probe Codex auth by reading `$CODEX_HOME/auth.json` (default `~/.codex`). - * The Codex SDK does not expose an `accountInfo()` equivalent, so file - * inspection is the cleanest non-network check — and it mirrors exactly - * what the executor's Codex prompt-service does at session start. - */ -async function probeCodexAuth(): Promise { - const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); - const authPath = join(codexHome, 'auth.json'); - - let parsed: CodexAuthFile; - try { - const raw = await fs.readFile(authPath, 'utf-8'); - parsed = JSON.parse(raw) as CodexAuthFile; - } catch { - // No auth.json, unreadable, or malformed — treat as not authenticated. - return null; - } - - // ChatGPT OAuth path — the CLI auto-refreshes via refresh_token, but - // OpenAI considers the session stale after ~8 days without a refresh. - if (parsed.tokens?.refresh_token) { - if (parsed.last_refresh) { - const refreshedAt = Date.parse(parsed.last_refresh); - if (Number.isFinite(refreshedAt) && Date.now() - refreshedAt > CODEX_SESSION_STALE_MS) { - return { - authenticated: false, - method: 'oauth', - hint: 'Codex ChatGPT session is stale (>8 days since last refresh). Run `codex` once to refresh.', - }; - } - } - return { - authenticated: true, - method: 'oauth', - hint: parsed.auth_mode ? `ChatGPT (${parsed.auth_mode})` : 'ChatGPT subscription auth', - }; - } - - // API key persisted into auth.json (set via `codex login --api-key`). - if (parsed.OPENAI_API_KEY) { - return { - authenticated: true, - method: 'api-key', - hint: 'Using OPENAI_API_KEY from ~/.codex/auth.json', - }; - } - - return null; -} - function isClaudeSubscriptionToken(token: string): boolean { return token.trim().startsWith('sk-ant-oat'); } @@ -413,7 +282,9 @@ export function createCheckAuthService(db: TenantScopeAwareDatabase) { // Need at least one auth-indicating field to call it real. const hasAuthSignal = !!( account && - (account.apiKeySource || account.tokenSource || account.email) + (isRealAuthSource(account.apiKeySource) || + isRealAuthSource(account.tokenSource) || + account.email) ); // Surface what the probe actually saw — invaluable when the wizard // says "authenticated" but the session can't find creds. @@ -425,9 +296,9 @@ export function createCheckAuthService(db: TenantScopeAwareDatabase) { }` ); if (hasAuthSignal && account) { - const method: AuthCheckResult['method'] = account.apiKeySource + const method: AuthCheckResult['method'] = isRealAuthSource(account.apiKeySource) ? 'api-key' - : account.tokenSource + : isRealAuthSource(account.tokenSource) ? 'oauth' : 'native'; const hintParts: string[] = []; diff --git a/apps/agor-daemon/src/services/native-auth-probe.ts b/apps/agor-daemon/src/services/native-auth-probe.ts new file mode 100644 index 0000000000..9a2541136e --- /dev/null +++ b/apps/agor-daemon/src/services/native-auth-probe.ts @@ -0,0 +1,168 @@ +/** + * Native (CLI / OAuth) auth probing for agentic tools, shared by the check-auth + * service and the missing-credential classifier. Statically imports the Claude + * Agent SDK, so consumers that must stay SDK-free (e.g. vitest'd hooks) should + * pull it in via dynamic `import()` rather than a top-level import. + */ + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { SDKUserMessage } from '@agor/core/sdk'; +import { Claude } from '@agor/core/sdk'; +import type { AuthCheckResult } from '@agor/core/types'; +import { isRealAuthSource } from './check-auth-helpers.js'; + +const SDK_AUTH_PROBE_TIMEOUT_MS = 10_000; +// Codex treats the OAuth session as stale after ~8 days (per OpenAI docs). +const CODEX_SESSION_STALE_MS = 8 * 24 * 60 * 60 * 1000; + +/** + * Verify Claude Code auth by spawning the SDK in streaming-input mode and + * reading `accountInfo()` from its init handshake. The SDK launches the + * `claude` CLI in the same context the executor will at session-start, so a + * successful probe means real sessions will resolve creds the same way. + * + * We pass an AsyncIterable that yields nothing — control requests like + * `accountInfo()` require streaming-input mode, but never yielding means no + * user message is sent and no API call is made. Cleanup releases the held + * iterable and closes the query so the subprocess exits. + * + * Returns null on any failure (CLI missing, no auth, timeout, etc.). + */ +export async function probeClaudeCodeAuth( + env?: Record +): Promise { + let releaseHeldInput!: () => void; + const heldInputPromise = new Promise((resolve) => { + releaseHeldInput = resolve; + }); + + // biome-ignore lint/correctness/useYield: intentional — holds the input stream open so the SDK enters streaming-input mode and accepts control requests like accountInfo(), but never sends a user message. + async function* neverYields(): AsyncIterable { + await heldInputPromise; + } + + const q = Claude.query({ + prompt: neverYields(), + options: env ? { env } : {}, + }); + + try { + const account = await Promise.race([ + q.accountInfo(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Auth probe timed out')), SDK_AUTH_PROBE_TIMEOUT_MS) + ), + ]); + return account ?? null; + } catch { + return null; + } finally { + releaseHeldInput(); + try { + q.close(); + } catch { + // best-effort cleanup + } + } +} + +/** + * Shape of `$CODEX_HOME/auth.json` — the file the codex CLI writes after a + * successful login. The executor reads from the same path (it explicitly + * does NOT override CODEX_HOME), so this is the authoritative signal for + * "will a Codex session start without a 'not logged in' error?" + */ +interface CodexAuthFile { + auth_mode?: string; + tokens?: { + access_token?: string; + refresh_token?: string; + id_token?: string; + }; + last_refresh?: string; + OPENAI_API_KEY?: string; +} + +export type CodexAuthProbeResult = { + authenticated: boolean; + method: AuthCheckResult['method']; + hint?: string; +}; + +/** + * Probe Codex auth by reading `$CODEX_HOME/auth.json` (default `~/.codex`). + * The Codex SDK does not expose an `accountInfo()` equivalent, so file + * inspection is the cleanest non-network check — and it mirrors exactly + * what the executor's Codex prompt-service does at session start. + */ +export async function probeCodexAuth(): Promise { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + const authPath = join(codexHome, 'auth.json'); + + let parsed: CodexAuthFile; + try { + const raw = await fs.readFile(authPath, 'utf-8'); + parsed = JSON.parse(raw) as CodexAuthFile; + } catch { + // No auth.json, unreadable, or malformed — treat as not authenticated. + return null; + } + + // ChatGPT OAuth path — the CLI auto-refreshes via refresh_token, but + // OpenAI considers the session stale after ~8 days without a refresh. + if (parsed.tokens?.refresh_token) { + if (parsed.last_refresh) { + const refreshedAt = Date.parse(parsed.last_refresh); + if (Number.isFinite(refreshedAt) && Date.now() - refreshedAt > CODEX_SESSION_STALE_MS) { + return { + authenticated: false, + method: 'oauth', + hint: 'Codex ChatGPT session is stale (>8 days since last refresh). Run `codex` once to refresh.', + }; + } + } + return { + authenticated: true, + method: 'oauth', + hint: parsed.auth_mode ? `ChatGPT (${parsed.auth_mode})` : 'ChatGPT subscription auth', + }; + } + + // API key persisted into auth.json (set via `codex login --api-key`). + if (parsed.OPENAI_API_KEY) { + return { + authenticated: true, + method: 'api-key', + hint: 'Using OPENAI_API_KEY from ~/.codex/auth.json', + }; + } + + return null; +} + +/** + * Whether native CLI/OAuth auth currently works for `tool`. This is the same + * "authenticated?" determination check-auth surfaces, reduced to a boolean for + * callers (like the missing-credential classifier) that only need yes/no. + */ +export async function checkNativeAuth(tool: string): Promise { + if (tool === 'claude-code') { + const account = await probeClaudeCodeAuth(); + // `accountInfo()` returns an object with all-optional fields; an empty {} + // means the SDK initialized but found no credentials. Require at least one + // real auth-indicating field to call it authenticated. + return !!( + account && + (isRealAuthSource(account.apiKeySource) || + isRealAuthSource(account.tokenSource) || + account.email) + ); + } + if (tool === 'codex') { + const result = await probeCodexAuth(); + return result?.authenticated === true; + } + return false; +} diff --git a/apps/agor-ui/src/components/App/App.tsx b/apps/agor-ui/src/components/App/App.tsx index c68374fc90..6cab430a99 100644 --- a/apps/agor-ui/src/components/App/App.tsx +++ b/apps/agor-ui/src/components/App/App.tsx @@ -1,4 +1,5 @@ import type { + AgenticToolName, AgorClient, Artifact, Board, @@ -403,6 +404,11 @@ export const App: React.FC = ({ const [leftPanelTab, setLeftPanelTab] = useState('teammate'); const [userSettingsOpen, setUserSettingsOpen] = useState(false); + // Deep-links the settings modal to a tool tab; cleared on close so a later + // plain settings click doesn't re-land on a stale tab. + const [userSettingsInitialTool, setUserSettingsInitialTool] = useState< + AgenticToolName | undefined + >(undefined); const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth ); @@ -1195,6 +1201,10 @@ export const App: React.FC = ({ onSessionClick: handleSessionClick, onOpenBranch: handleOpenBranchModal, onOpenTerminal: canOpenTerminal ? handleOpenTerminal : undefined, + onOpenAgenticToolSettings: (tool: AgenticToolName) => { + setUserSettingsInitialTool(tool); + setUserSettingsOpen(true); + }, }), [ stableOnSendPrompt, @@ -1713,9 +1723,10 @@ export const App: React.FC = ({ setThemeEditorOpen(false)} /> { setUserSettingsOpen(false); + setUserSettingsInitialTool(undefined); onUserSettingsClose?.(); }} user={user || null} diff --git a/apps/agor-ui/src/components/ConversationView/ConversationView.tsx b/apps/agor-ui/src/components/ConversationView/ConversationView.tsx index 41b8d6b9c2..66fb5366df 100644 --- a/apps/agor-ui/src/components/ConversationView/ConversationView.tsx +++ b/apps/agor-ui/src/components/ConversationView/ConversationView.tsx @@ -10,7 +10,14 @@ * - Auto-scrolling to latest content */ -import type { AgorClient, Message, PermissionScope, SessionID, User } from '@agor-live/client'; +import type { + AgenticToolName, + AgorClient, + Message, + PermissionScope, + SessionID, + User, +} from '@agor-live/client'; import { shortId, TaskStatus } from '@agor-live/client'; import { BranchesOutlined, CopyOutlined, ForkOutlined } from '@ant-design/icons'; import { Alert, Button, Spin, Typography, theme } from 'antd'; @@ -125,6 +132,8 @@ export interface ConversationViewProps { * When true, all task blocks are force-expanded (used by in-session search) */ forceExpandAll?: boolean; + + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; } export const ConversationView = React.memo( @@ -145,6 +154,7 @@ export const ConversationView = React.memo( genealogy, teammateEmoji, forceExpandAll = false, + onOpenAgenticToolSettings, }) => { const { token } = theme.useToken(); const [copied, copy] = useCopyToClipboard(); @@ -505,6 +515,7 @@ export const ConversationView = React.memo( teammateEmoji={teammateEmoji} isLatestTask={taskIndex === tasks.length - 1} client={client} + onOpenAgenticToolSettings={onOpenAgenticToolSettings} /> ))} diff --git a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx index da6ab86139..d6b2fafa66 100644 --- a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx +++ b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx @@ -10,6 +10,7 @@ */ import { + type AgenticToolName, type AgorClient, type ContentBlock as CoreContentBlock, type DiffEnrichment, @@ -33,6 +34,7 @@ import { AgorAvatar } from '../AgorAvatar'; import { CollapsibleMarkdown } from '../CollapsibleText/CollapsibleMarkdown'; import { CopyableContent } from '../CopyableContent'; import { MarkdownRenderer } from '../MarkdownRenderer'; +import { MissingCredentialPanel } from '../MissingCredentialPanel'; import { PermissionRequestBlock } from '../PermissionRequestBlock'; import { SystemMessage } from '../SystemMessage'; import { ThinkingBlock } from '../ThinkingBlock'; @@ -100,6 +102,7 @@ interface MessageBlockProps { allow: boolean, scope: PermissionScope ) => void; + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; } /** Get short description for a tool call (file path, pattern, command, etc.) */ @@ -330,6 +333,7 @@ const MessageBlockInner: React.FC = ({ onPermissionDecision, teammateEmoji, client = null, + onOpenAgenticToolSettings, }) => { const { token } = theme.useToken(); @@ -492,6 +496,17 @@ const MessageBlockInner: React.FC = ({ ); } + // Missing-credential failure — show the Connect-AI panel, not the raw error. + if (isSystem && message.metadata?.error_kind === 'missing_credential' && message.metadata?.tool) { + return ( + + ); + } + // Daemon restart / crash notice — injected by startup reconciliation. // Intentionally low-frequency and user-meaningful; contrast with PR #1116 // which filtered high-frequency SDK lifecycle noise. diff --git a/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx b/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx new file mode 100644 index 0000000000..9b5ff0504a --- /dev/null +++ b/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx @@ -0,0 +1,150 @@ +/** + * Connect-AI empty state shown in place of a raw provider auth error. Because + * the stored classification reflects whoever ran the failed prompt, the panel + * re-checks auth live for the current viewer via the `check-auth` service. + */ + +import type { AgenticToolName, AgorClient, AuthCheckResult } from '@agor-live/client'; +import { AGENTIC_TOOL_DISPLAY_NAMES, AGENTIC_TOOL_KEY_CREATION_URL } from '@agor-live/client'; +import { CheckCircleOutlined } from '@ant-design/icons'; +import { Button, Space, Spin, Typography, theme } from 'antd'; +import { useEffect, useState } from 'react'; +import { SystemMessage } from '../SystemMessage'; + +const { Text, Link } = Typography; + +/** Tools where an existing subscription/CLI login is a real alternative to an API key. */ +const NATIVE_AUTH_HINT: Partial> = { + 'claude-code': 'Already on a Claude Pro or Max plan? You can connect that instead of an API key.', + 'claude-code-cli': + 'Already on a Claude Pro or Max plan? You can connect that instead of an API key.', + codex: + 'Already signed in with ChatGPT on this machine? Codex can use that instead of an API key.', +}; + +export interface MissingCredentialPanelProps { + tool: AgenticToolName; + client?: AgorClient | null; + /** Opens Settings deep-linked to this tool's Agentic Tools tab. */ + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; +} + +export const MissingCredentialPanel: React.FC = ({ + tool, + client, + onOpenAgenticToolSettings, +}) => { + const { token } = theme.useToken(); + const [checking, setChecking] = useState(true); + const [authResult, setAuthResult] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + let cancelled = false; + if (!client) { + setChecking(false); + return; + } + setChecking(true); + client + .service('check-auth') + .create({ tool }) + .then((result) => { + if (!cancelled) setAuthResult(result as AuthCheckResult); + }) + .catch(() => { + if (!cancelled) setAuthResult(null); + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + }, [client, tool]); + + const displayName = AGENTIC_TOOL_DISPLAY_NAMES[tool] ?? tool; + const keyCreationUrl = AGENTIC_TOOL_KEY_CREATION_URL[tool]; + const nativeAuthHint = NATIVE_AUTH_HINT[tool]; + + if (dismissed) return null; + + if (checking) { + return ( + + + + Checking your {displayName} connection… + + + } + /> + ); + } + + // Current viewer already has a working credential — no CTA needed. + if (authResult?.authenticated) { + return ( + + + + This run needed a connected {displayName} account. Your account is already connected — + sending a new message should work. + + + } + /> + ); + } + + return ( + + + Agor doesn't have its own AI model — it drives {displayName} on your behalf, so it needs + a way to reach your account before this session can respond. + + +
+ +
+ + + {keyCreationUrl && ( + + Don't have a key yet?{' '} + + Get one from {displayName}'s console → + + + )} + {nativeAuthHint && ( + + {nativeAuthHint} + + )} + + + + } + /> + ); +}; diff --git a/apps/agor-ui/src/components/MissingCredentialPanel/index.ts b/apps/agor-ui/src/components/MissingCredentialPanel/index.ts new file mode 100644 index 0000000000..db6e699fb8 --- /dev/null +++ b/apps/agor-ui/src/components/MissingCredentialPanel/index.ts @@ -0,0 +1,2 @@ +export type { MissingCredentialPanelProps } from './MissingCredentialPanel'; +export { MissingCredentialPanel } from './MissingCredentialPanel'; diff --git a/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx b/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx index d087dd3d55..8c1dc4717f 100644 --- a/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx +++ b/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx @@ -26,6 +26,7 @@ import type { UserPreferences, } from '@agor-live/client'; import { + AGENTIC_TOOL_DISPLAY_NAMES, getDefaultPermissionMode, mapToCodexPermissionConfig, shortId, @@ -161,16 +162,6 @@ function apiKeyPlaceholder(agent: AgenticToolName, authMethod: AuthMethod = 'api return 'sk-ant-...'; } -const AGENT_LABELS: Record = { - 'claude-code': 'Claude Code', - 'claude-code-cli': 'Claude Code CLI', - codex: 'Codex', - gemini: 'Gemini', - opencode: 'OpenCode', - copilot: 'GitHub Copilot', - cursor: 'Cursor SDK', -}; - const RECOMMENDED_AGENT_OPTIONS: Array<{ value: AgenticToolName; title: string; eyebrow: string }> = [ { value: 'claude-code', title: 'Claude Code', eyebrow: 'Recommended' }, @@ -1067,8 +1058,8 @@ export function OnboardingWizard({ } - title={`${AGENT_LABELS[selectedAgent]} is configured`} - subTitle={`You're all set to use ${AGENT_LABELS[selectedAgent]}.`} + title={`${AGENTIC_TOOL_DISPLAY_NAMES[selectedAgent]} is configured`} + subTitle={`You're all set to use ${AGENTIC_TOOL_DISPLAY_NAMES[selectedAgent]}.`} />