Skip to content

Commit a5d2954

Browse files
bkudiessCopilot
andcommitted
fix(gateway): surface oauth auth urls in wizard protocol
Add structured auth metadata to wizard text steps so RPC wizard clients can show OAuth authorization URLs alongside the redirect paste prompt. Preserve existing CLI logging and local browser behavior while covering shared OAuth providers and Gemini. Co-authored-by: Copilot <[email protected]>
1 parent 9827490 commit a5d2954

17 files changed

Lines changed: 459 additions & 77 deletions

extensions/chutes/index.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,28 @@ async function runChutesOAuth(ctx: ProviderAuthContext): Promise<ProviderAuthRes
3838
})
3939
).trim();
4040
const clientSecret = normalizeOptionalString(process.env.CHUTES_CLIENT_SECRET);
41+
const presentsAuthChallenge = ctx.prompter.presentsAuthChallenge === true;
42+
const collapseManualIntro = isRemote && presentsAuthChallenge;
4143

42-
await ctx.prompter.note(
43-
isRemote
44-
? [
45-
"You are running in a remote/VPS environment.",
46-
"A URL will be shown for you to open in your LOCAL browser.",
47-
"After signing in, paste the redirect URL back here.",
48-
"",
49-
`Redirect URI: ${redirectUri}`,
50-
].join("\n")
51-
: [
52-
"Browser will open for Chutes authentication.",
53-
"If the callback doesn't auto-complete, paste the redirect URL.",
54-
"",
55-
`Redirect URI: ${redirectUri}`,
56-
].join("\n"),
57-
"Chutes OAuth",
58-
);
44+
if (!collapseManualIntro) {
45+
await ctx.prompter.note(
46+
isRemote
47+
? [
48+
"You are running in a remote/VPS environment.",
49+
"A URL will be shown for you to open in your LOCAL browser.",
50+
"After signing in, paste the redirect URL back here.",
51+
"",
52+
`Redirect URI: ${redirectUri}`,
53+
].join("\n")
54+
: [
55+
"Browser will open for Chutes authentication.",
56+
"If the callback doesn't auto-complete, paste the redirect URL.",
57+
"",
58+
`Redirect URI: ${redirectUri}`,
59+
].join("\n"),
60+
"Chutes OAuth",
61+
);
62+
}
5963

6064
const progress = ctx.prompter.progress("Starting Chutes OAuth…");
6165
try {
@@ -66,6 +70,13 @@ async function runChutesOAuth(ctx: ProviderAuthContext): Promise<ProviderAuthRes
6670
spin: progress,
6771
openUrl: ctx.openUrl,
6872
localBrowserMessage: "Complete sign-in in browser…",
73+
manualPromptMessage: collapseManualIntro
74+
? [
75+
"After signing in, paste the redirect URL back here.",
76+
"",
77+
`Redirect URI: ${redirectUri}`,
78+
].join("\n")
79+
: undefined,
6980
});
7081

7182
const creds = await loginChutes({

extensions/google/gemini-cli-provider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
7272
openUrl: ctx.openUrl,
7373
log: (msg) => ctx.runtime.log(msg),
7474
note: ctx.prompter.note,
75-
prompt: async (message) => ctx.prompter.text({ message }),
75+
presentsAuthChallenge: ctx.prompter.presentsAuthChallenge === true,
76+
prompt: async (message) =>
77+
ctx.prompter.text({
78+
message,
79+
}),
7680
progress: spin,
7781
});
7882

extensions/google/oauth.shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type GeminiCliOAuthCredentials = {
3737

3838
export type GeminiCliOAuthContext = {
3939
isRemote: boolean;
40+
presentsAuthChallenge?: boolean;
4041
openUrl: (url: string) => Promise<void>;
4142
log: (msg: string) => void;
4243
note: (message: string, title?: string) => Promise<void>;

extensions/google/oauth.test.ts

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import { join, parse } from "node:path";
33
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
44

5+
const oauthRuntimeMocks = vi.hoisted(() => ({
6+
loginGeminiCliOAuth: vi.fn(),
7+
}));
8+
59
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
610
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
711
"openclaw/plugin-sdk/runtime-env",
@@ -12,6 +16,10 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
1216
};
1317
});
1418

19+
vi.mock("./oauth.runtime.js", () => ({
20+
loginGeminiCliOAuth: oauthRuntimeMocks.loginGeminiCliOAuth,
21+
}));
22+
1523
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
1624
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
1725
"openclaw/plugin-sdk/ssrf-runtime",
@@ -756,32 +764,40 @@ describe("loginGeminiCliOAuth", () => {
756764

757765
type LoginGeminiCliOAuthFn = (options: {
758766
isRemote: boolean;
767+
presentsAuthChallenge?: boolean;
759768
openUrl: () => Promise<void>;
760769
log: (msg: string) => void;
761-
note: () => Promise<void>;
762-
prompt: () => Promise<string>;
770+
note: (message: string, title?: string) => Promise<void>;
771+
prompt: (message: string) => Promise<string>;
763772
progress: { update: () => void; stop: () => void };
764773
}) => Promise<{ projectId?: string }>;
765774

766-
async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) {
775+
async function runRemoteLoginWithCapturedAuthUrl(
776+
loginGeminiCliOAuth: LoginGeminiCliOAuthFn,
777+
options: { presentsAuthChallenge?: boolean } = {},
778+
) {
767779
let authUrl = "";
780+
const note = vi.fn(async () => {});
781+
const promptCalls: string[] = [];
768782
const result = await loginGeminiCliOAuth({
769783
isRemote: true,
784+
presentsAuthChallenge: options.presentsAuthChallenge,
770785
openUrl: async () => {},
771786
log: (msg) => {
772787
const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/);
773788
if (found?.[0]) {
774789
authUrl = found[0];
775790
}
776791
},
777-
note: async () => {},
778-
prompt: async () => {
792+
note,
793+
prompt: async (message) => {
794+
promptCalls.push(message);
779795
const state = new URL(authUrl).searchParams.get("state");
780796
return `http://localhost:8085/oauth2callback?code=oauth-code&state=${state}`;
781797
},
782798
progress: { update: () => {}, stop: () => {} },
783799
});
784-
return { result, authUrl };
800+
return { result, authUrl, note, promptCalls };
785801
}
786802

787803
async function runProjectDiscoveryExpectingProjectId(projectId: string) {
@@ -896,6 +912,62 @@ describe("loginGeminiCliOAuth", () => {
896912
expect(codeVerifier).not.toBe(authState);
897913
});
898914

915+
it("includes the manual OAuth auth URL in the prompt message", async () => {
916+
installGeminiOAuthFetchMock(({ url }) => {
917+
if (url === LOAD_PROD) {
918+
return responseJson({
919+
currentTier: { id: "standard-tier" },
920+
cloudaicompanionProject: { id: "prod-project" },
921+
});
922+
}
923+
return undefined;
924+
});
925+
926+
const { loginGeminiCliOAuth } = await import("./oauth.js");
927+
const { authUrl, promptCalls } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
928+
929+
expect(promptCalls).toEqual([
930+
[
931+
"Open this URL in your LOCAL browser:",
932+
"",
933+
authUrl,
934+
"",
935+
"After signing in, copy the redirect URL and paste it here:",
936+
].join("\n"),
937+
]);
938+
});
939+
940+
it("collapses manual OAuth instructions into the auth URL prompt for auth-presenting clients", async () => {
941+
installGeminiOAuthFetchMock(({ url }) => {
942+
if (url === LOAD_PROD) {
943+
return responseJson({
944+
currentTier: { id: "standard-tier" },
945+
cloudaicompanionProject: { id: "prod-project" },
946+
});
947+
}
948+
return undefined;
949+
});
950+
951+
const { loginGeminiCliOAuth } = await import("./oauth.js");
952+
const { authUrl, note, promptCalls } = await runRemoteLoginWithCapturedAuthUrl(
953+
loginGeminiCliOAuth,
954+
{
955+
presentsAuthChallenge: true,
956+
},
957+
);
958+
959+
expect(note).not.toHaveBeenCalled();
960+
expect(promptCalls).toEqual([
961+
[
962+
"Open this URL in your LOCAL browser:",
963+
"",
964+
authUrl,
965+
"",
966+
"After signing in, copy the redirect URL and paste it here:",
967+
].join("\n"),
968+
]);
969+
});
970+
899971
it("rejects manual callback input when the returned state does not match", async () => {
900972
const { loginGeminiCliOAuth } = await import("./oauth.js");
901973

@@ -1083,3 +1155,75 @@ describe("loginGeminiCliOAuth", () => {
10831155
expect(result.expires).toBeLessThanOrEqual(beforeRefresh);
10841156
});
10851157
});
1158+
1159+
describe("Gemini CLI provider OAuth wiring", () => {
1160+
beforeEach(() => {
1161+
oauthRuntimeMocks.loginGeminiCliOAuth.mockReset();
1162+
});
1163+
1164+
it("forwards manual OAuth URLs into wizard text messages", async () => {
1165+
const authUrl = "https://accounts.google.com/o/oauth2/v2/auth?state=abc";
1166+
oauthRuntimeMocks.loginGeminiCliOAuth.mockImplementation(
1167+
async (ctx: {
1168+
presentsAuthChallenge?: boolean;
1169+
prompt: (message: string) => Promise<string>;
1170+
}) => {
1171+
expect(ctx.presentsAuthChallenge).toBe(true);
1172+
await ctx.prompt(
1173+
[
1174+
"Open this URL in your LOCAL browser:",
1175+
"",
1176+
authUrl,
1177+
"",
1178+
"After signing in, copy the redirect URL and paste it here:",
1179+
].join("\n"),
1180+
);
1181+
return {
1182+
access: "access-token",
1183+
refresh: "refresh-token",
1184+
expires: Date.now() + 60_000,
1185+
1186+
};
1187+
},
1188+
);
1189+
1190+
const { buildGoogleGeminiCliProvider } = await import("./gemini-cli-provider.js");
1191+
const provider = buildGoogleGeminiCliProvider();
1192+
const method = provider.auth?.find((candidate) => candidate.id === "oauth");
1193+
if (!method) {
1194+
throw new Error("expected Gemini CLI OAuth method");
1195+
}
1196+
1197+
const spin = { update: vi.fn(), stop: vi.fn() };
1198+
const text = vi.fn(async () => "http://localhost:8085/oauth2callback?code=oauth-code");
1199+
1200+
await method.run({
1201+
isRemote: true,
1202+
openUrl: vi.fn(async () => {}),
1203+
runtime: {
1204+
log: vi.fn(),
1205+
error: vi.fn(),
1206+
exit: vi.fn((code: number) => {
1207+
throw new Error(`exit:${code}`);
1208+
}),
1209+
},
1210+
prompter: {
1211+
presentsAuthChallenge: true,
1212+
note: vi.fn(async () => {}),
1213+
confirm: vi.fn(async () => true),
1214+
progress: vi.fn(() => spin),
1215+
text,
1216+
},
1217+
} as never);
1218+
1219+
expect(text).toHaveBeenCalledWith({
1220+
message: [
1221+
"Open this URL in your LOCAL browser:",
1222+
"",
1223+
authUrl,
1224+
"",
1225+
"After signing in, copy the redirect URL and paste it here:",
1226+
].join("\n"),
1227+
});
1228+
});
1229+
});

extensions/google/oauth.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,24 @@ export async function loginGeminiCliOAuth(
1919
ctx: GeminiCliOAuthContext,
2020
): Promise<GeminiCliOAuthCredentials> {
2121
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
22-
await ctx.note(
23-
needsManual
24-
? [
25-
"You are running in a remote/VPS environment.",
26-
"A URL will be shown for you to open in your LOCAL browser.",
27-
"After signing in, copy the redirect URL and paste it back here.",
28-
].join("\n")
29-
: [
30-
"Browser will open for Google authentication.",
31-
"Sign in with your Google account for Gemini CLI access.",
32-
"The callback will be captured automatically on localhost:8085.",
33-
].join("\n"),
34-
"Gemini CLI OAuth",
35-
);
22+
const presentsAuthChallenge = ctx.presentsAuthChallenge === true;
23+
const collapseManualIntro = needsManual && presentsAuthChallenge;
24+
if (!collapseManualIntro) {
25+
await ctx.note(
26+
needsManual
27+
? [
28+
"You are running in a remote/VPS environment.",
29+
"A URL will be shown for you to open in your LOCAL browser.",
30+
"After signing in, copy the redirect URL and paste it back here.",
31+
].join("\n")
32+
: [
33+
"Browser will open for Google authentication.",
34+
"Sign in with your Google account for Gemini CLI access.",
35+
"The callback will be captured automatically on localhost:8085.",
36+
].join("\n"),
37+
"Gemini CLI OAuth",
38+
);
39+
}
3640

3741
const { verifier, challenge } = generatePkce();
3842
const state = generateOAuthState();
@@ -82,7 +86,15 @@ async function manualFlow(
8286
ctx.progress.update("OAuth URL ready");
8387
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
8488
ctx.progress.update("Waiting for you to paste the callback URL...");
85-
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
89+
const callbackInput = await ctx.prompt(
90+
[
91+
"Open this URL in your LOCAL browser:",
92+
"",
93+
authUrl,
94+
"",
95+
"After signing in, copy the redirect URL and paste it here:",
96+
].join("\n"),
97+
);
8698
const parsed = parseCallbackInput(callbackInput);
8799
if ("error" in parsed) {
88100
throw new Error(parsed.error, cause ? { cause } : undefined);

extensions/msteams/src/oauth.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,15 @@ async function manualFlow(
111111
ctx.progress.update("OAuth URL ready");
112112
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
113113
ctx.progress.update("Waiting for you to paste the callback URL...");
114-
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
114+
const callbackInput = await ctx.prompt(
115+
[
116+
"Open this URL in your LOCAL browser:",
117+
"",
118+
authUrl,
119+
"",
120+
"After signing in, copy the redirect URL and paste it here:",
121+
].join("\n"),
122+
);
115123
const parsed = parseCallbackInput(callbackInput, state);
116124
if ("error" in parsed) {
117125
throw new Error(parsed.error, cause ? { cause } : undefined);

0 commit comments

Comments
 (0)