Skip to content

Commit 34ad72e

Browse files
douhualiliCopilot
authored andcommitted
fix(gateway): deliver OAuth authorization URLs to non-terminal clients
OAuth redirect-paste setup over the RPC wizard only printed the authorization URL to gateway stdout (or opened a browser on the gateway host), so non-terminal clients (e.g. the Windows companion) never received the link needed to sign in. Carry the authorization URL in-band in the wizard text prompt via a documented presentsAuthChallenge prompter capability; terminal CLI logging is preserved. Covers the shared OAuth helper plus Google, OpenAI ChatGPT/Codex, OpenRouter, Chutes, and MS Teams provider paths. The shared helper now also surfaces the URL in-band on a LOCAL gateway when the client presents auth challenges (a companion cannot use a browser opened on the gateway host), and a contract test locks in that auth-presenting clients always receive the URL in a protocol-visible prompt. Refs #94661. Co-authored-by: Copilot <[email protected]>
1 parent 1bfa278 commit 34ad72e

17 files changed

Lines changed: 503 additions & 71 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
@@ -73,7 +73,11 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
7373
openUrl: ctx.openUrl,
7474
log: (msg) => ctx.runtime.log(msg),
7575
note: ctx.prompter.note,
76-
prompt: async (message) => ctx.prompter.text({ message }),
76+
presentsAuthChallenge: ctx.prompter.presentsAuthChallenge === true,
77+
prompt: async (message) =>
78+
ctx.prompter.text({
79+
message,
80+
}),
7781
progress: spin,
7882
});
7983

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)