[codex] Add Cursor Agent auth diagnostics (#1538)

* Add Cursor Agent auth diagnostics

* Handle Cursor not logged in auth status

* Address Cursor auth review feedback

* Classify Cursor stdout auth failures
This commit is contained in:
Caprika 2026-05-13 20:25:34 +08:00 committed by GitHub
parent eaf64dc2b9
commit 06dbde51f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 656 additions and 17 deletions

View file

@ -34,6 +34,11 @@ import { diagnoseClaudeCliFailure } from './claude-diagnostics.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { agentCliEnvForAgent, validateAgentCliEnv } from './app-config.js';
import {
classifyAgentAuthFailure,
cursorAuthGuidance,
probeAgentAuthStatus,
} from './runtimes/auth.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import {
isLoopbackApiHost,
@ -1062,6 +1067,18 @@ async function testAgentConnectionInternal(
const detail = redactSecrets(
error instanceof Error ? error.message : String(error),
);
const auth = classifyAgentAuthFailure(input.agentId, detail);
if (auth?.status === 'missing') {
console.warn(`[test:agent] ${def.name} → auth_required: ${detail}`);
return {
ok: false,
kind: 'agent_auth_required',
latencyMs,
model,
agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(),
};
}
if (detail && isLikelyModelErrorText(detail)) {
console.warn(
`[test:agent] ${def.name} → not_found_model: ${detail}`,
@ -1125,14 +1142,26 @@ async function testAgentConnectionInternal(
}
const stdinMode =
def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
const env = applyAgentLaunchEnv(spawnEnvForAgent(
const baseEnv = spawnEnvForAgent(
input.agentId,
{
...process.env,
...(def.env || {}),
},
configuredAgentEnv,
), executableResolution);
);
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
if (auth?.status === 'missing') {
return {
ok: false,
kind: 'agent_auth_required',
latencyMs: Date.now() - start,
model,
agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(),
};
}
const invocation = createCommandInvocation({
command: executableResolution.launchPath,
args,
@ -1228,6 +1257,28 @@ async function testAgentConnectionInternal(
const stderrTail = sink.getStderrTail().trim();
const rawStdoutTail = sink.getRawStdoutTail().trim();
const acpFatal = Boolean(acpSession?.hasFatalError?.());
const rawDetail = [
winner.code != null ? `exit ${winner.code}` : null,
winner.signal ? `signal ${winner.signal}` : null,
stderrTail ? `stderr: ${stderrTail.slice(-200)}` : null,
rawStdoutTail || buffered
? `stdout: ${(rawStdoutTail || buffered).slice(-200)}`
: null,
]
.filter(Boolean)
.join(' · ');
const auth = classifyAgentAuthFailure(input.agentId, rawDetail);
if (auth?.status === 'missing') {
console.warn(`[test:agent] ${def.name} → auth_required: ${redactSecrets(rawDetail)}`);
return {
ok: false,
kind: 'agent_auth_required',
latencyMs,
model,
agentName: def.name,
detail: auth.message ?? cursorAuthGuidance(),
};
}
const claudeDiagnostic = diagnoseClaudeCliFailure({
agentId: input.agentId,
exitCode: winner.code,
@ -1250,14 +1301,7 @@ async function testAgentConnectionInternal(
};
}
const detail = redactSecrets(
[
winner.code != null ? `exit ${winner.code}` : null,
winner.signal ? `signal ${winner.signal}` : null,
stderrTail ? `stderr: ${stderrTail.slice(-200)}` : null,
buffered ? `stdout: ${buffered.slice(-200)}` : null,
]
.filter(Boolean)
.join(' · '),
rawDetail,
);
const guidance = redactSecrets(
`${codexExecutableGuidance(

View file

@ -0,0 +1,76 @@
import { execAgentFile } from './invocation.js';
import type { RuntimeEnv } from './types.js';
export type AgentAuthProbeResult = {
status: 'ok' | 'missing' | 'unknown';
message?: string;
};
const CURSOR_AUTH_GUIDANCE =
'Cursor Agent is not authenticated. Run `cursor-agent login`, then `cursor-agent status`, and retry. For automation, ensure CURSOR_API_KEY is set in the Open Design process environment.';
export function cursorAuthGuidance(): string {
return CURSOR_AUTH_GUIDANCE;
}
export function isCursorAuthFailureText(text: string): boolean {
const value = String(text || '');
if (!value.trim()) return false;
return (
/authentication required/i.test(value) ||
/not authenticated/i.test(value) ||
/not logged in/i.test(value) ||
/unauthenticated/i.test(value) ||
/agent login/i.test(value) ||
/cursor_api_key/i.test(value)
);
}
export function classifyAgentAuthFailure(
agentId: string,
text: string,
): AgentAuthProbeResult | null {
if (agentId !== 'cursor-agent') return null;
if (!isCursorAuthFailureText(text)) return null;
return {
status: 'missing',
message: cursorAuthGuidance(),
};
}
export async function probeAgentAuthStatus(
agentId: string,
resolvedBin: string,
env: RuntimeEnv,
): Promise<AgentAuthProbeResult | null> {
if (agentId !== 'cursor-agent') return null;
try {
const { stdout, stderr } = await execAgentFile(resolvedBin, ['status'], {
env,
timeout: 5000,
maxBuffer: 1024 * 1024,
});
const output = `${stdout ?? ''}\n${stderr ?? ''}`;
if (isCursorAuthFailureText(output)) {
return { status: 'missing', message: cursorAuthGuidance() };
}
return { status: 'ok' };
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: unknown;
stderr?: unknown;
};
const output = [
err.message,
typeof err.stdout === 'string' ? err.stdout : '',
typeof err.stderr === 'string' ? err.stderr : '',
].join('\n');
if (isCursorAuthFailureText(output)) {
return { status: 'missing', message: cursorAuthGuidance() };
}
return {
status: 'unknown',
message: 'Cursor Agent authentication status could not be verified with `cursor-agent status`.',
};
}
}

View file

@ -3,6 +3,7 @@ import { AGENT_DEFS } from './registry.js';
import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js';
import { resolveAgentExecutable } from './executables.js';
import { spawnEnvForAgent } from './env.js';
import { probeAgentAuthStatus } from './auth.js';
import { agentCapabilities } from './capabilities.js';
import { installMetaForAgent } from './metadata.js';
import type {
@ -161,12 +162,19 @@ async function probe(
agentCapabilities.set(def.id, caps);
}
const models = await fetchModels(def, resolved, probeEnv);
const auth = await probeAgentAuthStatus(def.id, resolved, probeEnv);
return {
...stripFns(def),
models,
available: true,
path: resolved,
version: outcome.version,
...(auth
? {
authStatus: auth.status,
...(auth.message ? { authMessage: auth.message } : {}),
}
: {}),
...installMetaForAgent(def.id),
};
}

View file

@ -81,6 +81,8 @@ export type DetectedAgent = Omit<
> & {
models: RuntimeModelOption[];
available: boolean;
authStatus?: 'ok' | 'missing' | 'unknown';
authMessage?: string;
path?: string;
version?: string | null;
};

View file

@ -71,6 +71,7 @@ import { handleCritiqueInterrupt } from './critique/interrupt-handler.js';
import { handleCritiqueArtifact } from './critique/artifact-handler.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { classifyAgentAuthFailure, cursorAuthGuidance } from './runtimes/auth.js';
import { createQoderStreamHandler } from './qoder-stream.js';
import { subscribe as subscribeFileEvents } from './project-watchers.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
@ -3992,9 +3993,7 @@ export async function startServer({
child.stdout.on('data', (chunk) => {
childStdoutSeen = true;
noteAgentActivity();
if (def.id === 'claude') {
agentStdoutTail = `${agentStdoutTail}${chunk}`.slice(-1000);
}
agentStdoutTail = `${agentStdoutTail}${chunk}`.slice(-2000);
});
// ---- Memory: assistant-reply buffer for LLM extraction --------------
@ -4179,6 +4178,23 @@ export async function startServer({
if (agentStreamError) return;
agentStreamError = String(ev.message || 'Agent stream error');
clearInactivityWatchdog();
const authFailure = classifyAgentAuthFailure(
agentId,
[
agentStreamError,
typeof ev.raw === 'string' ? ev.raw : '',
agentStdoutTail,
agentStderrTail,
].join('\n'),
);
if (authFailure?.status === 'missing') {
send('error', createSseErrorPayload(
'AGENT_AUTH_REQUIRED',
cursorAuthGuidance(),
{ retryable: true },
));
return;
}
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', agentStreamError, {
details: ev.raw ? { raw: ev.raw } : undefined,
retryable: false,
@ -4292,9 +4308,7 @@ export async function startServer({
run.acpSession = acpSession;
child.stderr.on('data', (chunk) => {
noteAgentActivity();
if (def.id === 'claude') {
agentStderrTail = `${agentStderrTail}${chunk}`.slice(-1000);
}
agentStderrTail = `${agentStderrTail}${chunk}`.slice(-2000);
send('stderr', { chunk });
});
@ -4315,6 +4329,18 @@ export async function startServer({
if (agentStreamError) {
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
}
if (
code !== 0 &&
!run.cancelRequested &&
classifyAgentAuthFailure(agentId, `${agentStderrTail}\n${agentStdoutTail}`)?.status === 'missing'
) {
send('error', createSseErrorPayload(
'AGENT_AUTH_REQUIRED',
cursorAuthGuidance(),
{ retryable: true },
));
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
}
// Empty-output guard: a clean `code === 0` exit on a stream we are
// tracking, with no error frame and no substantive event, means the
// run silently finished without producing anything visible. That used

View file

@ -162,6 +162,192 @@ process.exit(0);
);
});
it('classifies Cursor Agent authentication stderr as a typed run error', async () => {
await withFakeAgent(
'cursor-agent',
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.");
process.exit(1);
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'cursor-agent',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
expect(eventsBody).toContain('cursor-agent login');
expect(eventsBody).toContain('cursor-agent status');
expect(statusBody.status).toBe('failed');
},
);
});
it('classifies Cursor Agent Not logged in stderr as a typed run error', async () => {
await withFakeAgent(
'cursor-agent',
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
console.error('Not logged in');
process.exit(1);
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'cursor-agent',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
expect(eventsBody).toContain('cursor-agent login');
expect(eventsBody).toContain('cursor-agent status');
expect(statusBody.status).toBe('failed');
},
);
});
it('classifies Cursor Agent stdout auth text as a typed run error', async () => {
await withFakeAgent(
'cursor-agent',
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
console.log('ConnectError: [unauthenticated]');
process.exit(1);
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'cursor-agent',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
expect(eventsBody).toContain('cursor-agent login');
expect(eventsBody).toContain('cursor-agent status');
expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
expect(statusBody.status).toBe('failed');
},
);
});
it('classifies Cursor Agent stdout error payloads as typed auth failures', async () => {
const cursorErrorLine = JSON.stringify({
type: 'error',
message: 'Error: [unauthenticated] Error',
});
await withFakeAgent(
'cursor-agent',
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
console.log(${JSON.stringify(cursorErrorLine)});
process.exit(1);
`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'cursor-agent',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
expect(eventsBody).toContain('cursor-agent login');
expect(eventsBody).toContain('cursor-agent status');
expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
expect(statusBody.status).toBe('failed');
},
);
});
it('surfaces Qoder assistant error records through the SSE error channel', async () => {
const qoderErrorLine = JSON.stringify({
type: 'assistant',

View file

@ -91,6 +91,10 @@ async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promi
return withFakeAgent('opencode', script, run);
}
async function withFakeCursorAgent<T>(script: string, run: () => Promise<T>): Promise<T> {
return withFakeAgent('cursor-agent', script, run);
}
async function waitForFile(file: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@ -1501,6 +1505,140 @@ setTimeout(() => process.exit(0), 50);
);
});
it('reports Cursor Agent status auth failures before running the smoke prompt', async () => {
await withFakeCursorAgent(
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('No models available for this account.');
process.exit(0);
}
if (args[0] === 'status') {
console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.");
process.exit(1);
}
console.error('smoke prompt should not run when status reports missing auth');
process.exit(1);
`,
async () => {
const res = await realFetch(`${baseUrl}/api/test/connection`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'agent', agentId: 'cursor-agent' }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: false,
kind: 'agent_auth_required',
agentName: 'Cursor Agent',
detail: expect.stringContaining('cursor-agent login'),
});
},
);
});
it('reports Cursor Agent Not logged in status before running the smoke prompt', async () => {
await withFakeCursorAgent(
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('No models available for this account.');
process.exit(0);
}
if (args[0] === 'status') {
console.error('Not logged in');
process.exit(1);
}
console.error('smoke prompt should not run when status reports missing auth');
process.exit(1);
`,
async () => {
const res = await realFetch(`${baseUrl}/api/test/connection`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ mode: 'agent', agentId: 'cursor-agent' }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
ok: false,
kind: 'agent_auth_required',
agentName: 'Cursor Agent',
detail: expect.stringContaining('cursor-agent login'),
});
},
);
});
it('classifies Cursor Agent runtime auth failures from stderr', async () => {
await withFakeCursorAgent(
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
if (args[0] === 'status') {
console.log('Authenticated');
process.exit(0);
}
console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.");
process.exit(1);
`,
async () => {
const result = await testAgentConnection({ agentId: 'cursor-agent' });
expect(result).toMatchObject({
ok: false,
kind: 'agent_auth_required',
agentName: 'Cursor Agent',
detail: expect.stringContaining('cursor-agent status'),
});
},
);
});
it('keeps non-auth Cursor Agent runtime failures on the generic spawn path', async () => {
await withFakeCursorAgent(
`
const args = process.argv.slice(2);
if (args[0] === '--version') {
console.log('2026.05.07-test');
process.exit(0);
}
if (args[0] === 'models') {
console.log('auto');
process.exit(0);
}
if (args[0] === 'status') {
console.log('Authenticated');
process.exit(0);
}
console.error('workspace path does not exist');
process.exit(1);
`,
async () => {
const result = await testAgentConnection({ agentId: 'cursor-agent' });
expect(result).toMatchObject({
ok: false,
kind: 'agent_spawn_failed',
agentName: 'Cursor Agent',
});
expect(result.detail).toContain('workspace path does not exist');
},
);
});
it('rejects invalid custom model ids before spawning an agent', async () => {
const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-argv-'));
const argvFile = path.join(markerDir, 'argv.json');

View file

@ -3,6 +3,7 @@ import { homedir } from 'node:os';
import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
@ -332,6 +333,111 @@ test('detectAgents applies configured env while probing the CLI', async () => {
}
});
test('detectAgents marks Cursor Agent auth ok when cursor-agent status succeeds', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-auth-ok-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo auto& exit /b 0\r\nif "%~1"=="status" echo Authenticated& exit /b 0\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "auto"; exit 0; fi\nif [ "$1" = "status" ]; then echo "Authenticated"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'ok');
assert.equal(detected?.authMessage, undefined);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents keeps Cursor Agent available when auth is missing', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-auth-missing-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo No models available for this account.& exit /b 0\r\nif "%~1"=="status" echo Authentication required. Please run agent login first, or set CURSOR_API_KEY environment variable. 1>&2& exit /b 1\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "No models available for this account."; exit 0; fi\nif [ "$1" = "status" ]; then echo "Authentication required. Please run agent login first, or set CURSOR_API_KEY environment variable." >&2; exit 1; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'missing');
assert.match(detected?.authMessage ?? '', /cursor-agent login/);
assert.deepEqual(
detected?.models.map((model) => model.id),
['default', 'auto', 'sonnet-4', 'sonnet-4-thinking', 'gpt-5'],
);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('detectAgents treats Cursor Agent Not logged in status as missing auth', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-cursor-not-logged-in-'));
try {
await withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], async () => {
const bin = join(dir, process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" echo 2026.05.07-test& exit /b 0\r\nif "%~1"=="models" echo No models available for this account.& exit /b 0\r\nif "%~1"=="status" echo Not logged in 1>&2& exit /b 1\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "2026.05.07-test"; exit 0; fi\nif [ "$1" = "models" ]; then echo "No models available for this account."; exit 0; fi\nif [ "$1" = "status" ]; then echo "Not logged in" >&2; exit 1; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;
const agents = await detectAgents();
const detected = agents.find((agent) => agent.id === 'cursor-agent');
assert.equal(detected?.available, true);
assert.equal(detected?.authStatus, 'missing');
assert.match(detected?.authMessage ?? '', /cursor-agent login/);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('Cursor auth matcher covers current unauthenticated Cursor error records', () => {
assert.equal(isCursorAuthFailureText('ConnectError: [unauthenticated]'), true);
assert.equal(isCursorAuthFailureText('Error: [unauthenticated] Error'), true);
});
// Windows env-var names are case-insensitive at the kernel level, but
// spreading process.env into a plain object loses Node's case-insensitive
// accessor — a `Anthropic_Api_Key` key would survive a literal

View file

@ -1131,6 +1131,8 @@ export function SettingsDialog({
return t('settings.testTimeout', { ms });
case 'agent_not_installed':
return t('settings.testAgentMissing', { agentName });
case 'agent_auth_required':
return result.detail || 'Agent authentication is required.';
case 'agent_spawn_failed':
return t('settings.testAgentSpawn', {
agentName,
@ -1918,7 +1920,15 @@ export function SettingsDialog({
<div className="agent-card-body">
<div className="agent-card-name">{a.name}</div>
<div className="agent-card-meta">
{a.version ? (
{a.authStatus === 'missing' ? (
<span title={a.authMessage ?? a.path ?? ''}>
{t('settings.agentAuthRequired')}
</span>
) : a.authStatus === 'unknown' ? (
<span title={a.authMessage ?? a.path ?? ''}>
{t('settings.agentAuthUnknown')}
</span>
) : a.version ? (
<span title={a.path ?? ''}>{a.version}</span>
) : (
<span title={a.path ?? ''}>

View file

@ -96,6 +96,8 @@ export const ar: Dict = {
'settings.agentInstall.stepSelect': 'اختر بطاقة الوكيل عندما يظهر كأنه مثبت.',
'settings.noAgentsDetected':
'لم يتم اكتشاف أي وكلاء بعد. قم بتثبيت Claude Code أو Codex أو Devin أو Gemini CLI أو OpenCode أو Cursor Agent أو Qwen أو GitHub Copilot CLI، ثم اضغط على إعادة المسح.',
'settings.agentAuthRequired': 'المصادقة مطلوبة',
'settings.agentAuthUnknown': 'حالة المصادقة غير معروفة',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'ملء المزوّد سريعًا',
'settings.customProvider': 'مزوّد مخصص',

View file

@ -96,6 +96,8 @@ export const de: Dict = {
'settings.agentInstall.stepSelect': 'Waehlen Sie die Agent-Karte aus, sobald sie als installiert angezeigt wird.',
'settings.noAgentsDetected':
'Noch keine Agents erkannt. Installieren Sie Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen oder GitHub Copilot CLI und klicken Sie dann auf Neu scannen.',
'settings.agentAuthRequired': 'Authentifizierung erforderlich',
'settings.agentAuthUnknown': 'Authentifizierungsstatus unbekannt',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Anbieter schnell ausfüllen',
'settings.customProvider': 'Benutzerdefinierter Anbieter',

View file

@ -94,6 +94,8 @@ export const en: Dict = {
'settings.agentInstall.stepSelect': 'Select the agent card once it appears as installed.',
'settings.noAgentsDetected':
'No agents detected yet. Install one of Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, or GitHub Copilot CLI, then click Rescan.',
'settings.agentAuthRequired': 'Authentication required',
'settings.agentAuthUnknown': 'Auth status unknown',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Quick fill provider',
'settings.customProvider': 'Custom provider',

View file

@ -96,6 +96,8 @@ export const esES: Dict = {
'settings.agentInstall.stepSelect': 'Selecciona la tarjeta del agente cuando aparezca como instalado.',
'settings.noAgentsDetected':
'Aún no se ha detectado ningún agente. Instala Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen o GitHub Copilot CLI y pulsa Reescanear.',
'settings.agentAuthRequired': 'Autenticación requerida',
'settings.agentAuthUnknown': 'Estado de autenticación desconocido',
'settings.apiSection': 'API de Anthropic',
'settings.quickFillProvider': 'Rellenar proveedor',
'settings.customProvider': 'Proveedor personalizado',

View file

@ -96,6 +96,8 @@ export const fa: Dict = {
'settings.agentInstall.stepSelect': 'وقتی عامل به‌صورت نصب‌شده نمایش داده شد، کارت آن را انتخاب کنید.',
'settings.noAgentsDetected':
'هنوز هیچ عاملی شناسایی نشده. یکی از Claude Code، Codex، Gemini CLI، OpenCode، Cursor Agent، Qwen یا GitHub Copilot CLI را نصب کنید، سپس روی اسکن مجدد کلیک کنید.',
'settings.agentAuthRequired': 'احراز هویت لازم است',
'settings.agentAuthUnknown': 'وضعیت احراز هویت نامشخص است',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'پر کردن سریع ارائه‌دهنده',
'settings.customProvider': 'ارائه‌دهنده سفارشی',

View file

@ -96,6 +96,8 @@ export const fr: Dict = {
'settings.agentInstall.stepSelect': 'Sélectionnez la carte de l\'agent une fois qu\'elle apparaît comme installée.',
'settings.noAgentsDetected':
'Aucun agent détecté pour l\'instant. Installez Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen ou GitHub Copilot CLI, puis cliquez sur Réanalyser.',
'settings.agentAuthRequired': 'Authentification requise',
'settings.agentAuthUnknown': 'Statut dauthentification inconnu',
'settings.apiSection': 'API Anthropic',
'settings.quickFillProvider': 'Remplissage rapide du fournisseur',
'settings.customProvider': 'Fournisseur personnalisé',

View file

@ -96,6 +96,8 @@ export const hu: Dict = {
'settings.agentInstall.stepSelect': 'Válaszd ki az ügynök kártyáját, amint telepítettként jelenik meg.',
'settings.noAgentsDetected':
'Még nincs észlelt ügynök. Telepítsd a Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen vagy GitHub Copilot CLI valamelyikét, majd kattints az Újraellenőrzésre.',
'settings.agentAuthRequired': 'Hitelesítés szükséges',
'settings.agentAuthUnknown': 'A hitelesítési állapot ismeretlen',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Szolgáltató gyors kitöltése',
'settings.customProvider': 'Egyéni szolgáltató',

View file

@ -95,6 +95,8 @@ export const id: Dict = {
'settings.agentInstall.stepSelect': 'Pilih kartu agen setelah statusnya terpasang.',
'settings.noAgentsDetected':
'Belum ada agent terdeteksi. Pasang salah satu dari Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, atau GitHub Copilot CLI, lalu klik pindai ulang.',
'settings.agentAuthRequired': 'Autentikasi diperlukan',
'settings.agentAuthUnknown': 'Status autentikasi tidak diketahui',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Provider isi cepat',
'settings.customProvider': 'Provider kustom',

View file

@ -96,6 +96,8 @@ export const ja: Dict = {
'settings.agentInstall.stepSelect': 'インストール済みとして表示されたらエージェントカードを選択します。',
'settings.noAgentsDetected':
'エージェントが検出されませんでした。Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent、Qwen、または GitHub Copilot CLI のいずれかをインストールして、再スキャンをクリックしてください。',
'settings.agentAuthRequired': '認証が必要です',
'settings.agentAuthUnknown': '認証状態は不明です',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'プロバイダーをクイック入力',
'settings.customProvider': 'カスタムプロバイダー',

View file

@ -96,6 +96,8 @@ export const ko: Dict = {
'settings.agentInstall.stepSelect': '설치됨으로 표시되면 해당 에이전트 카드를 선택하세요.',
'settings.noAgentsDetected':
'에이전트가 감지되지 않았습니다. Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen 또는 GitHub Copilot CLI 중 하나를 설치한 후 다시 스캔을 클릭하세요.',
'settings.agentAuthRequired': '인증 필요',
'settings.agentAuthUnknown': '인증 상태를 알 수 없음',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': '제공자 빠른 입력',
'settings.customProvider': '사용자 지정 제공자',

View file

@ -96,6 +96,8 @@ export const pl: Dict = {
'settings.agentInstall.stepSelect': 'Wybierz kartę agenta, gdy pojawi się jako zainstalowany.',
'settings.noAgentsDetected':
'Nie wykryto jeszcze żadnych agentów. Zainstaluj Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen lub GitHub Copilot CLI, a następnie kliknij Ponów skanowanie.',
'settings.agentAuthRequired': 'Wymagane uwierzytelnienie',
'settings.agentAuthUnknown': 'Stan uwierzytelnienia nieznany',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Szybkie wypełnienie dostawcy',
'settings.customProvider': 'Niestandardowy dostawca',

View file

@ -96,6 +96,8 @@ export const ptBR: Dict = {
'settings.agentInstall.stepSelect': 'Selecione o cartão do agente quando ele aparecer como instalado.',
'settings.noAgentsDetected':
'Nenhum agente detectado ainda. Instale Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen ou GitHub Copilot CLI e clique em Reescanear.',
'settings.agentAuthRequired': 'Autenticação necessária',
'settings.agentAuthUnknown': 'Status de autenticação desconhecido',
'settings.apiSection': 'API da Anthropic',
'settings.quickFillProvider': 'Preencher provedor',
'settings.customProvider': 'Provedor personalizado',

View file

@ -96,6 +96,8 @@ export const ru: Dict = {
'settings.agentInstall.stepSelect': 'Выберите карточку агента, когда он появится как установленный.',
'settings.noAgentsDetected':
'Агенты ещё не обнаружены. Установите один из следующих инструментов: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen или GitHub Copilot CLI, затем нажмите «Пересканировать».',
'settings.agentAuthRequired': 'Требуется аутентификация',
'settings.agentAuthUnknown': 'Статус аутентификации неизвестен',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Быстро заполнить провайдера',
'settings.customProvider': 'Пользовательский провайдер',

View file

@ -93,6 +93,8 @@ export const th: Dict = {
'settings.agentInstall.stepRescan': 'คลิกสแกนใหม่ในส่วนนี้',
'settings.agentInstall.stepSelect': 'เลือกการ์ดเอเจนต์เมื่อแสดงว่าได้ติดตั้งแล้ว',
'settings.noAgentsDetected': 'ยังไม่พบเอเจนต์ โปรดติดตั้งอย่างใดอย่างหนึ่ง: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen หรือ GitHub Copilot CLI แล้วคลิกสแกนใหม่',
'settings.agentAuthRequired': 'ต้องยืนยันตัวตน',
'settings.agentAuthUnknown': 'ไม่ทราบสถานะการยืนยันตัวตน',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'เลือกผู้ให้บริการอย่างรวดเร็ว',
'settings.customProvider': 'กำหนดผู้ให้บริการเอง',

View file

@ -96,6 +96,8 @@ export const tr: Dict = {
'settings.agentInstall.stepSelect': 'Ajan yüklü olarak göründüğünde kartını seç.',
'settings.noAgentsDetected':
'Hiçbir ajan tespit edilemedi. Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, veya GitHub Copilot CLIlardan birini kurun ve yeniden tarayın.',
'settings.agentAuthRequired': 'Kimlik doğrulama gerekli',
'settings.agentAuthUnknown': 'Kimlik doğrulama durumu bilinmiyor',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Sağlayıcıyı hızlı doldur',
'settings.customProvider': 'Özel sağlayıcı',

View file

@ -97,6 +97,8 @@ export const uk: Dict = {
'settings.agentInstall.stepSelect': 'Виберіть картку агента, коли він з\'явиться як встановлений.',
'settings.noAgentsDetected':
'Агентів ще не виявлено. Встановіть один з: Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen або GitHub Copilot CLI, а потім натисніть Переканувати.',
'settings.agentAuthRequired': 'Потрібна автентифікація',
'settings.agentAuthUnknown': 'Стан автентифікації невідомий',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Швидко заповнити провайдера',
'settings.customProvider': 'Власний провайдер',

View file

@ -93,6 +93,8 @@ export const zhCN: Dict = {
'settings.agentInstall.stepSelect': '当代理显示为已安装后,选择该代理卡片。',
'settings.noAgentsDetected':
'尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent、Qwen 或 GitHub Copilot CLI 中的一个,然后点击「重新扫描」。',
'settings.agentAuthRequired': '需要认证',
'settings.agentAuthUnknown': '认证状态未知',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': '快速填充提供方',
'settings.customProvider': '自定义提供方',

View file

@ -95,6 +95,8 @@ export const zhTW: Dict = {
'settings.agentInstall.stepSelect': '當代理顯示為已安裝後,選擇該代理卡片。',
'settings.noAgentsDetected':
'尚未偵測到任何代理。請安裝 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 其中之一,然後點擊「重新掃描」。',
'settings.agentAuthRequired': '需要認證',
'settings.agentAuthUnknown': '認證狀態未知',
'settings.apiSection': 'API 設定',
'settings.quickFillProvider': '快速填入提供方',
'settings.customProvider': '自訂提供方',

View file

@ -120,6 +120,8 @@ export interface Dict {
'settings.agentInstall.stepRescan': string;
'settings.agentInstall.stepSelect': string;
'settings.noAgentsDetected': string;
'settings.agentAuthRequired': string;
'settings.agentAuthUnknown': string;
'settings.apiSection': string;
'settings.quickFillProvider': string;
'settings.customProvider': string;

View file

@ -169,6 +169,7 @@ describe('SettingsDialog test status variant', () => {
'upstream_unavailable',
'timeout',
'agent_not_installed',
'agent_auth_required',
'agent_spawn_failed',
'unknown',
] as const) {

View file

@ -135,6 +135,7 @@ export type ConnectionTestKind =
| 'upstream_unavailable'
| 'timeout'
| 'agent_not_installed'
| 'agent_auth_required'
| 'agent_spawn_failed'
| 'unknown';

View file

@ -8,6 +8,8 @@ export interface AgentInfo {
name: string;
bin: string;
available: boolean;
authStatus?: 'ok' | 'missing' | 'unknown';
authMessage?: string;
path?: string;
version?: string | null;
models?: AgentModelOption[];

View file

@ -11,6 +11,7 @@ export const API_ERROR_CODES = [
'UNSUPPORTED_MEDIA_TYPE',
'VALIDATION_FAILED',
'AGENT_UNAVAILABLE',
'AGENT_AUTH_REQUIRED',
'AGENT_EXECUTION_FAILED',
'AGENT_PROMPT_TOO_LARGE',
'PROJECT_NOT_FOUND',