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
10 changes: 8 additions & 2 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ import { InboxNoticeCard } from "./conversation/InboxNoticeCard";
import { mergeInboxNotices, type InboxMessage } from "./conversation/inboxNotice";
import { configureNativeSidebarPreviewVariant, configureSidebarPreviewVariant } from "./sidebarPreview";
import { cloudCredentialRecovery } from "./cloudCredentialRecovery";
import { cloudAgentLogin } from "./cloudAgentLogin";
import { cloudAgentLogin, localApiKeyLogin } from "./cloudAgentLogin";
import { handoffTurns } from "./cloudHandoffTurn";
import { configureTurnActionsPlacement } from "./turnActionsPlacement";
import { archiveRow, markUnreadRow } from "./header/archiveRow";
Expand Down Expand Up @@ -7618,7 +7618,13 @@ function AcpmuxPane() {
),
// A Cloud chat's agent signs in on its own machine (Leo 2026-11-01: "yes those are
// important"): its own login, never this Mac's. Its page opens as a link click does here.
cloudSignIn: cloudAgentLogin({
// A chat here whose built-in Claude Code or Codex is not signed in offers Use an API key
// (Leo 2026-10-26: "if user picks a model they don't have api key, we offer them to add
// credits or add their own api key").
...((login: Parameters<typeof cloudAgentLogin>[0]) => ({
cloudSignIn: cloudAgentLogin(login),
apiKeySignIn: localApiKeyLogin(login),
}))({
connected,
machineID: cloudMachineID,
sessionId: snapshot.sessionId,
Expand Down
37 changes: 29 additions & 8 deletions webviews/src/agent-session/acpmux/cloudAgentLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,8 @@ export class LoginStepError extends Error {
}
}

/// The sign-in a Cloud chat's sign-in failure offers, or undefined where there is none: a chat
/// not connected, not on a Cloud machine, not Claude or Codex, or on an acpmux that does not
/// advertise `agent_login`.
export function cloudAgentLogin(options: {
/// What ``cloudAgentLogin`` and ``localApiKeyLogin`` take.
type LoginOptions = {
connected: boolean;
machineID: string | undefined;
sessionId: string | undefined;
Expand All @@ -157,11 +155,34 @@ export function cloudAgentLogin(options: {
openURL: (url: string) => void;
isCurrent: (sessionId: string) => boolean;
currentClient: () => LoginClient | undefined;
}): CloudSignIn | undefined {
const { connected, machineID, sessionId, harness, supported, gesture, openURL, isCurrent, currentClient } = options;
};

/// The sign-in a Cloud chat's sign-in failure offers, or undefined where there is none: a chat
/// not connected, not on a Cloud machine, not Claude or Codex, or on an acpmux that does not
/// advertise `agent_login`.
export function cloudAgentLogin(options: LoginOptions): CloudSignIn | undefined {
return options.machineID ? agentLogin(options) : undefined;
}

/// Use an API key for a chat on this Mac whose built-in Claude Code or Codex is not signed in (Leo
/// 2026-10-26: "if user picks a model they don't have api key, we offer them to add credits or add
/// their own api key"). acpmux saves the key where the agent's own CLI reads it (Claude's
/// `settings.json` `env.ANTHROPIC_API_KEY`, `codex login --with-api-key`) and restarts the chat on it.
/// Only the built-in `claude` and `codex` harnesses: a route (`claude-cr`, `claude-sr`) sets its own
/// key and endpoint, which a saved key would never reach. Undefined for a Cloud chat (its card is
/// ``cloudAgentLogin``'s).
export function localApiKeyLogin(options: LoginOptions): CloudSignIn | undefined {
const harness = options.harness?.split("/").pop();
if (options.machineID || (harness !== "claude" && harness !== "codex")) return undefined;
return agentLogin(options);
}

/// The agent's own login on the chat's machine, or undefined for a chat not connected, not Claude or
/// Codex, or on an acpmux that does not advertise `agent_login`.
function agentLogin(options: LoginOptions): CloudSignIn | undefined {
const { connected, sessionId, harness, supported, gesture, openURL, isCurrent, currentClient } = options;
const agent = agentKey(harness?.split("/").pop());
if (!connected || !machineID || !sessionId || !supported || (agent !== "claude" && agent !== "codex"))
return undefined;
if (!connected || !sessionId || !supported || (agent !== "claude" && agent !== "codex")) return undefined;
const client = () => {
const current = currentClient();
if (!current || !isCurrent(sessionId)) throw new LoginStepError("failed");
Expand Down
11 changes: 11 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/TurnRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
reauthenticate,
transferCloudCredentials,
cloudSignIn,
apiKeySignIn,
resume,
compact,
route,
Expand Down Expand Up @@ -373,6 +374,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) {
compact={compact}
reauthenticate={reauthenticate}
cloudSignIn={cloudSignIn}
apiKeySignIn={apiKeySignIn}
retryRowId={row.retryRowId}
prompt={prompt}
retry={failedRetry}
Expand Down Expand Up @@ -482,6 +484,7 @@ function TurnFailure({
compact,
reauthenticate,
cloudSignIn,
apiKeySignIn,
retryRowId,
prompt,
retry,
Expand All @@ -500,6 +503,9 @@ function TurnFailure({
reauthenticate?: () => void;
/// A Cloud chat's own sign-in on its machine (cloudAgentLogin.ts, CloudSignIn.tsx).
cloudSignIn?: CloudSignInActions;
/// A chat here whose built-in Claude Code or Codex is not signed in: Use an API key
/// (cloudAgentLogin.ts `localApiKeyLogin`).
apiKeySignIn?: CloudSignInActions;
/// The failed prompt's row and text, which the Cloud sign-in sends again once signed in.
retryRowId?: string;
prompt?: string;
Expand Down Expand Up @@ -557,6 +563,10 @@ function TurnFailure({
// card keeps only Use an API key of the sign-in, never Sign in to Claude, which would dead-end.
const teamMachine = isTeamMachineSignIn(kind, route);
const cloudSignInLeads = recovery === "sign-in" && cloudSignIn !== undefined && !signInFirst && !teamMachine;
// A chat here whose built-in Claude Code or Codex is not signed in (Leo 2026-10-26: "if user picks a
// model they don't have api key, we offer them to add credits or add their own api key"): Use an API
// key follows Sign in again, outlined; the key goes where the agent's own CLI reads it.
const localKey = recovery === "sign-in" && cloudSignIn === undefined ? apiKeySignIn : undefined;
// Continue or Retry is the way out, or the second one after Switch model for a key or proxy
// failure, or after Sign in again; never for trust (errorRecovery.ts).
const offersRetry = goOn !== undefined && (recovery !== "sign-in" || signInFirst);
Expand Down Expand Up @@ -646,6 +656,7 @@ function TurnFailure({
{t("turn.signInAgain")}
</button>
)}
{localKey && <CloudSignIn signIn={localKey} rowId={retryRowId} prompt={prompt} keyOnly />}
{offersRetry && (
<button
type="button"
Expand Down
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/turnActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export type TurnActions = {
/// A Cloud chat's agent signs in on its own machine (cloudAgentLogin.ts): its own login run there,
/// or an API key saved there, never this Mac's login. Absent where acpmux lacks `agent_login`.
cloudSignIn?: import("../cloudAgentLogin").CloudSignIn;
/// A chat on this Mac whose built-in Claude Code or Codex is not signed in: Use an API key saves
/// one where that agent reads it (cloudAgentLogin.ts `localApiKeyLogin`). Absent for a Cloud chat.
apiKeySignIn?: import("../cloudAgentLogin").CloudSignIn;
/// Who serves this chat's turns, for failure copy (cx-w10a): the harness profile's display name and id.
route?: import("../failureCopy").FailureRoute;
/// Opens the model and harness picker (a failure's "Switch model" action).
Expand Down
83 changes: 79 additions & 4 deletions webviews/test/cloud-signin-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
// the person's click. These tests drive the card and its wire with a mocked client.
import { afterAll, beforeEach, expect, test } from "bun:test";
import { JSDOM, VirtualConsole } from "jsdom";
import { cloudAgentLogin, loginFailure, signInURL } from "../src/agent-session/acpmux/cloudAgentLogin";
import {
cloudAgentLogin,
localApiKeyLogin,
loginFailure,
signInURL,
} from "../src/agent-session/acpmux/cloudAgentLogin";

const dom = new JSDOM("<!doctype html><div id=root></div>", {
url: "http://localhost/",
Expand Down Expand Up @@ -115,13 +120,19 @@ function mockClient(replies: Partial<Record<string, (...args: unknown[]) => unkn

function signInFor(
client: ReturnType<typeof mockClient>["client"],
options: { harness?: string; tickets?: (string | undefined)[]; opened?: string[]; supported?: boolean } = {},
options: {
harness?: string;
tickets?: (string | undefined)[];
opened?: string[];
supported?: boolean;
local?: boolean;
} = {},
) {
const tickets = options.tickets ?? ["ticket-1", "ticket-2"];
const intents: unknown[] = [];
const signIn = cloudAgentLogin({
const signIn = (options.local ? localApiKeyLogin : cloudAgentLogin)({
connected: true,
machineID: "provider-vm-1",
machineID: options.local ? undefined : "provider-vm-1",
sessionId: "chat-1",
harness: options.harness ?? "claude",
supported: options.supported ?? true,
Expand Down Expand Up @@ -522,3 +533,67 @@ test("a card that goes away cancels its login and sends nothing after", async ()
await flush();
expect(sent).toEqual([]);
});

// Leo 2026-10-26: "if user picks a model they don't have api key, we offer them to add credits or add
// their own api key". A chat on this Mac whose built-in Claude Code or Codex is not signed in offers
// Use an API key beside Sign in again; acpmux saves it where the agent's own CLI reads it.
const localRoute = { name: "Claude Code", harness: "claude" };

test("Use an API key is offered only for a connected built-in Claude or Codex chat on this Mac", () => {
const { client } = mockClient();
const base = {
connected: true,
machineID: undefined,
sessionId: "chat-1",
harness: "claude",
supported: true,
gesture: async () => undefined,
openURL: () => undefined,
isCurrent: () => true,
currentClient: () => client as never,
};
expect(localApiKeyLogin(base)?.agent).toBe("claude");
expect(localApiKeyLogin({ ...base, harness: "codex" })?.agent).toBe("codex");
// A route sets its own key and endpoint: a saved key would never reach it.
expect(localApiKeyLogin({ ...base, harness: "claude-cr" })).toBeUndefined();
expect(localApiKeyLogin({ ...base, harness: "opencode" })).toBeUndefined();
// A Cloud chat's card is cloudAgentLogin's.
expect(localApiKeyLogin({ ...base, machineID: "vm" })).toBeUndefined();
expect(localApiKeyLogin({ ...base, connected: false })).toBeUndefined();
expect(localApiKeyLogin({ ...base, supported: false })).toBeUndefined();
});

test("a signed-out chat here offers Sign in again, then Use an API key, which saves and sends again", async () => {
const { client, calls, sent } = mockClient({ apiKey: () => ({ saved: true, restarted: true }) });
const { signIn, intents } = signInFor(client, { local: true });
await render({
route: localRoute,
apiKeySignIn: signIn,
reauthenticate: () => undefined,
switchModel: () => undefined,
});
expect(labels().slice(0, 2)).toEqual(["Sign in again", "Use an API key"]);
expect(button("Sign in again")!.classList.contains("cv-turn-error__primary")).toBe(true);
expect(button("Use an API key")!.classList.contains("cv-turn-error__primary")).toBe(false);
expect(button("Sign in to Claude")).toBeUndefined();
await act(async () => button("Use an API key")!.click());
const input = host.querySelector<HTMLInputElement>(".cv-turn-signin__input")!;
expect(input.type).toBe("password");
await typeInto(input, "sk-ant-local-key");
await act(async () => button("Save")!.click());
await flush();
expect(calls).toContainEqual(["apiKey", "claude", "chat-1", "sk-ant-local-key"]);
expect(host.innerHTML).not.toContain("sk-ant-local-key");
await act(async () => button("Send again")!.click());
await flush();
await flush();
expect(intents).toHaveLength(1);
expect(sent).toHaveLength(1);
});

test("no Use an API key here on a failure that is not a sign-in", async () => {
const { client } = mockClient();
const { signIn } = signInFor(client, { local: true });
await render({ route: localRoute, apiKeySignIn: signIn, retry: () => undefined }, "API Error: 529 overloaded_error");
expect(button("Use an API key")).toBeUndefined();
});
Loading