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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ function formatKeyPart(part: string, platform: NodeJS.Platform): string {
const normalized = normalizeKeyPart(part);
const arrow = formatArrowKey(normalized);
if (arrow) return arrow;
if (platform === "darwin") {
if (normalized === "ctrl") return "Cmd";
if (normalized === "alt") return "Option";
// Terminals send the literal Control key on macOS, so never label it Cmd.
if (platform === "darwin" && normalized === "alt") {
return "Option";
}
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Text(theme.fg("muted", keyHint("tui.select.cancel", "cancel")), 0, 0));
this.tui.requestRender();

return this.waitForInput();
}

/**
* Wait for the next submission of the already-visible input.
*/
waitForInput(): Promise<string> {
return new Promise((resolve, reject) => {
this.inputResolver = resolve;
this.inputRejecter = reject;
Expand Down Expand Up @@ -194,10 +201,7 @@ export class LoginDialogComponent extends Container implements Focusable {
this.input.setValue("");
this.tui.requestRender();

return new Promise((resolve, reject) => {
this.inputResolver = resolve;
this.inputRejecter = reject;
});
return this.waitForInput();
}

/**
Expand Down
153 changes: 70 additions & 83 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6051,7 +6051,7 @@ export class InteractiveMode {
if (providerOption.authType === "oauth") {
resolve(await this.showLoginDialog(providerOption.id, providerOption.name));
} else if (providerOption.id === PRIME_INFERENCE_PROVIDER_ID) {
resolve(await this.showPrimeInferenceApiKeyLoginDialog());
resolve(await this.showPrimeInferenceLoginDialog());
} else if (providerOption.id === BEDROCK_PROVIDER_ID) {
resolve(await this.showBedrockSetupDialog(providerOption.id, providerOption.name));
} else {
Expand Down Expand Up @@ -6292,6 +6292,30 @@ export class InteractiveMode {
}
}

private async completePrimeInferenceLogin(
apiKey: string,
dialog: LoginDialogComponent,
closeDialog: () => void,
): Promise<AuthenticationResult> {
const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID);
const previousPrimeTeam =
previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined;
this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, {
type: "api_key",
key: apiKey,
...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}),
});
const teamStatus = await this.selectPrimeInferenceTeam(apiKey, dialog);

closeDialog();
return await this.completeProviderAuthentication(
PRIME_INFERENCE_PROVIDER_ID,
PRIME_INFERENCE_PROVIDER_NAME,
"api_key",
teamStatus,
);
}

private async showPrimeInferenceLoginDialog(): Promise<AuthenticationResult> {
const dialog = new LoginDialogComponent(
this.ui,
Expand All @@ -6309,111 +6333,74 @@ export class InteractiveMode {
this.ui.requestRender();
};

// The browser challenge gets its own controller so a manually pasted key
// can stop the polling without tearing down the dialog.
const browserAbort = new AbortController();
const onDialogAbort = () => browserAbort.abort();
dialog.signal.addEventListener("abort", onDialogAbort, { once: true });

let resolveManualKey: (apiKey: string) => void = () => {};
const manualKeyEntry = new Promise<{ apiKey: string; source: "manual" }>((resolve) => {
resolveManualKey = (apiKey) => resolve({ apiKey, source: "manual" });
});

try {
const result = await loginPrimeInference({
const browserLogin = loginPrimeInference({
onAuth: (info) => {
dialog.showAuth(info.url, info.instructions);
dialog.showWaiting("Waiting for browser authentication...");
void (async () => {
let value = (
await dialog.showManualInput("Complete the sign-in in your browser, or paste an API key below:")
).trim();
while (!value) {
value = (await dialog.waitForInput()).trim();
}
resolveManualKey(value);
})().catch(() => {
// Cancellation surfaces through the dialog signal.
});
},
onProgress: (message) => {
dialog.showProgress(message);
},
signal: dialog.signal,
signal: browserAbort.signal,
});
// Promise.race observes the rejection below, but keep a dedicated handler
// so an aborted browser flow can never surface as an unhandled rejection.
browserLogin.catch(() => {});

const result = await Promise.race([browserLogin, manualKeyEntry]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual key UI after challenge

Medium Severity

Manual API key entry is wired only inside the onAuth callback, so if loginPrimeInference fails before the browser challenge is created (for example challenge HTTP errors or malformed responses), Promise.race rejects and the dialog closes without ever showing the paste-key fallback that the removed API-key-only login dialog always offered.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a67a46f. Configure here.

if (dialog.signal.aborted) {
closeDialog();
return { status: "cancelled" };
}

const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID);
const previousPrimeTeam =
previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined;
this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, {
type: "api_key",
key: result.apiKey,
...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}),
});
const teamStatus = await this.selectPrimeInferenceTeam(result.apiKey, dialog);

closeDialog();
return await this.completeProviderAuthentication(
PRIME_INFERENCE_PROVIDER_ID,
PRIME_INFERENCE_PROVIDER_NAME,
"api_key",
teamStatus,
);
} catch (error: unknown) {
closeDialog();
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg !== "Login cancelled") {
this.showError(`Failed to login to ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`);
return { status: "failed" };
}
return { status: "cancelled" };
}
}

private async showPrimeInferenceApiKeyLoginDialog(): Promise<AuthenticationResult> {
const dialog = new LoginDialogComponent(
this.ui,
PRIME_INFERENCE_PROVIDER_ID,
(_success, _message) => {
// Completion handled below
},
PRIME_INFERENCE_PROVIDER_NAME,
);

const handle = this.showFullPaneOverlay(dialog, 88);

const closeDialog = () => {
handle.hide();
this.ui.requestRender();
};

try {
const apiKey = (await dialog.showPrompt("Enter API key:")).trim();
if (!apiKey) {
throw new Error("API key cannot be empty.");
}

dialog.showProgress("Checking Prime Inference access...");
const config = loadPrimeCliConfig();
const access = await checkPrimeInferenceAccess(apiKey, config.baseUrl, { signal: dialog.signal });
if (dialog.signal.aborted) {
closeDialog();
return { status: "cancelled" };
}
if (!access.ok) {
const status = access.status === undefined ? "" : `HTTP ${access.status}: `;
throw new Error(`Prime API key does not have Prime Inference access (${status}${access.message})`);
if (result.source === "manual") {
browserAbort.abort();
Comment thread
kevinjosethomas marked this conversation as resolved.
dialog.showProgress("Checking Prime Inference access...");
const config = loadPrimeCliConfig();
const access = await checkPrimeInferenceAccess(result.apiKey, config.baseUrl, { signal: dialog.signal });
if (dialog.signal.aborted) {
closeDialog();
return { status: "cancelled" };
}
if (!access.ok) {
const status = access.status === undefined ? "" : `HTTP ${access.status}: `;
throw new Error(`Prime API key does not have Prime Inference access (${status}${access.message})`);
}
}

const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID);
const previousPrimeTeam =
previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined;
this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, {
type: "api_key",
key: apiKey,
...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}),
});
const teamStatus = await this.selectPrimeInferenceTeam(apiKey, dialog);

closeDialog();
return await this.completeProviderAuthentication(
PRIME_INFERENCE_PROVIDER_ID,
PRIME_INFERENCE_PROVIDER_NAME,
"api_key",
teamStatus,
);
return await this.completePrimeInferenceLogin(result.apiKey, dialog, closeDialog);
} catch (error: unknown) {
closeDialog();
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg !== "Login cancelled") {
this.showError(`Failed to save API key for ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`);
this.showError(`Failed to login to ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`);
return { status: "failed" };
}
return { status: "cancelled" };
} finally {
dialog.signal.removeEventListener("abort", onDialogAbort);
}
}

Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/test/keybinding-hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import { formatKeyText } from "../src/modes/interactive/components/keybinding-hints.js";

describe("keybinding hint formatting", () => {
it("uses macOS modifier names on darwin", () => {
expect(formatKeyText("ctrl+p", "darwin")).toBe("Cmd+P");
it("uses macOS modifier names on darwin but keeps Ctrl literal", () => {
expect(formatKeyText("ctrl+p", "darwin")).toBe("Ctrl+P");
expect(formatKeyText("alt+enter", "darwin")).toBe("Option+Enter");
expect(formatKeyText("shift+ctrl+p/alt+up", "darwin")).toBe("Shift+Cmd+P/Option+↑");
expect(formatKeyText("shift+ctrl+p/alt+up", "darwin")).toBe("Shift+Ctrl+P/Option+↑");
});

it("keeps canonical modifier names on linux", () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/coding-agent/test/login-dialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ describe("LoginDialogComponent", () => {
}
});

it("cancels the prompt with esc and ctrl+c", async () => {
for (const key of ["\x1b", "\x03"]) {
const dialog = new LoginDialogComponent(createFakeTui(), "prime-inference", () => {}, "Prime Inference");
const prompt = dialog.showPrompt("Enter API key:");
dialog.handleInput(key);
await expect(prompt).rejects.toThrow("Login cancelled");
}
});

it("re-arms manual input after an empty submission", async () => {
const dialog = new LoginDialogComponent(createFakeTui(), "prime-inference", () => {}, "Prime Inference");
dialog.showAuth("https://example.com/challenge", "Code: abc-123");

const first = dialog.showManualInput("Or paste an API key below:");
dialog.handleInput("\r");
await expect(first).resolves.toBe("");

const second = dialog.waitForInput();
dialog.handleInput("p");
dialog.handleInput("k");
dialog.handleInput("\r");
await expect(second).resolves.toBe("pk");
});

it("renders API key prompts without shell input markers", () => {
const dialog = new LoginDialogComponent(createFakeTui(), "openai", () => {}, "OpenAI");

Expand Down