From 238e586db615654718515601c7f726561f7a266e Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Tue, 5 May 2026 03:39:03 +0000 Subject: [PATCH 1/5] fix(oauth): route assistant oauth connect --callback-transport=gateway through daemon IPC --- .../__tests__/oauth-connect-routes.test.ts | 267 ++++++++++++++++++ .../commands/oauth/__tests__/connect.test.ts | 207 ++++++++++++++ assistant/src/cli/commands/oauth/connect.ts | 97 ++++++- .../__tests__/oauth-connect-state.test.ts | 92 ++++++ assistant/src/oauth/oauth-connect-state.ts | 72 +++++ assistant/src/runtime/auth/route-policy.ts | 2 + assistant/src/runtime/routes/index.ts | 2 + .../runtime/routes/oauth-connect-routes.ts | 147 ++++++++++ 8 files changed, 885 insertions(+), 1 deletion(-) create mode 100644 assistant/src/__tests__/oauth-connect-routes.test.ts create mode 100644 assistant/src/oauth/__tests__/oauth-connect-state.test.ts create mode 100644 assistant/src/oauth/oauth-connect-state.ts create mode 100644 assistant/src/runtime/routes/oauth-connect-routes.ts diff --git a/assistant/src/__tests__/oauth-connect-routes.test.ts b/assistant/src/__tests__/oauth-connect-routes.test.ts new file mode 100644 index 00000000000..c3bb0ec176d --- /dev/null +++ b/assistant/src/__tests__/oauth-connect-routes.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +// ── Module mocks (must precede imports) ─────────────────────────────────────── + +type OrchestrateOptions = { + service: string; + clientId: string; + clientSecret?: string; + callbackTransport?: string; + requestedScopes?: string[]; + isInteractive: boolean; + onDeferredComplete?: (r: { + success: boolean; + service: string; + accountInfo?: string; + error?: string; + }) => void; +}; + +let capturedOnDeferredComplete: OrchestrateOptions["onDeferredComplete"] | undefined; +let mockOrchestrateResult: Record = { + success: true, + deferred: true, + authorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=test", + state: "test-state-uuid-abc123", + service: "google", +}; + +mock.module("../oauth/connect-orchestrator.js", () => ({ + orchestrateOAuthConnect: async (opts: OrchestrateOptions) => { + capturedOnDeferredComplete = opts.onDeferredComplete; + return mockOrchestrateResult; + }, +})); + +mock.module("../util/logger.js", () => ({ + getLogger: () => + new Proxy({} as Record, { + get: () => () => {}, + }), +})); + +// NOTE: Do NOT mock oauth-connect-state — use the real module so we can +// verify state transitions via getOAuthConnectState. + +// ── Import SUT after mocks ───────────────────────────────────────────────────── + +const { ROUTES } = await import("../runtime/routes/oauth-connect-routes.js"); +const { BadRequestError, InternalError, NotFoundError } = await import( + "../runtime/routes/errors.js" +); +const { _clearAllOAuthConnectStates, getOAuthConnectState } = await import( + "../oauth/oauth-connect-state.js" +); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function findRoute(operationId: string) { + const route = ROUTES.find((r) => r.operationId === operationId); + if (!route) throw new Error(`Route ${operationId} not found`); + return route; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("oauth-connect-routes", () => { + describe("POST internal/oauth/connect/start", () => { + beforeEach(() => { + capturedOnDeferredComplete = undefined; + mockOrchestrateResult = { + success: true, + deferred: true, + authorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=test", + state: "test-state-uuid-abc123", + service: "google", + }; + _clearAllOAuthConnectStates(); + }); + + test("happy path returns auth_url and state, sets pending in state map", async () => { + const result = await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + expect(result).toEqual({ + auth_url: "https://accounts.google.com/o/oauth2/auth?client_id=test", + state: "test-state-uuid-abc123", + }); + // State map should have pending entry + expect(getOAuthConnectState("test-state-uuid-abc123")).toMatchObject({ + status: "pending", + service: "google", + }); + }); + + test("invalid callbackTransport throws BadRequestError", async () => { + await expect( + findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "ftp", + }, + }), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("missing clientId throws BadRequestError", async () => { + await expect( + findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + callbackTransport: "gateway", + }, + }), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("missing service throws BadRequestError", async () => { + await expect( + findRoute("internal_oauth_connect_start").handler({ + body: { + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }), + ).rejects.toBeInstanceOf(BadRequestError); + }); + + test("orchestrator returns success:false throws InternalError", async () => { + mockOrchestrateResult = { + success: false, + error: "provider configuration error", + deferred: false, + }; + await expect( + findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }), + ).rejects.toBeInstanceOf(InternalError); + }); + + test("loopback callbackTransport is also accepted", async () => { + const result = await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "loopback", + }, + }); + expect(result).toMatchObject({ + auth_url: "https://accounts.google.com/o/oauth2/auth?client_id=test", + state: "test-state-uuid-abc123", + }); + }); + }); + + describe("GET internal/oauth/connect/status/:state", () => { + beforeEach(() => { + _clearAllOAuthConnectStates(); + capturedOnDeferredComplete = undefined; + mockOrchestrateResult = { + success: true, + deferred: true, + authorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=test", + state: "test-state-uuid-abc123", + service: "google", + }; + }); + + test("returns pending after start", async () => { + await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + const result = findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "test-state-uuid-abc123" }, + }); + expect(result).toMatchObject({ status: "pending", service: "google" }); + }); + + test("returns complete after onDeferredComplete fires with success", async () => { + await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + // Fire the onDeferredComplete callback manually + capturedOnDeferredComplete?.({ + success: true, + service: "google", + accountInfo: "user@example.com", + }); + const result = findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "test-state-uuid-abc123" }, + }); + expect(result).toMatchObject({ + status: "complete", + service: "google", + account_info: "user@example.com", + }); + }); + + test("returns error after onDeferredComplete fires with failure", async () => { + await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + capturedOnDeferredComplete?.({ + success: false, + service: "google", + error: "exchange failed", + }); + const result = findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "test-state-uuid-abc123" }, + }); + expect(result).toMatchObject({ + status: "error", + service: "google", + error: "exchange failed", + }); + }); + + test("throws NotFoundError for unknown state", () => { + expect(() => + findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "nonexistent-state" }, + }), + ).toThrow(NotFoundError); + }); + + test("complete without accountInfo does not include account_info field", async () => { + await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + capturedOnDeferredComplete?.({ + success: true, + service: "google", + // No accountInfo + }); + const result = findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "test-state-uuid-abc123" }, + }) as Record; + expect(result.status).toBe("complete"); + expect(result.account_info).toBeUndefined(); + }); + }); +}); diff --git a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts b/assistant/src/cli/commands/oauth/__tests__/connect.test.ts index d7cb3a86218..a1abb321908 100644 --- a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts +++ b/assistant/src/cli/commands/oauth/__tests__/connect.test.ts @@ -42,6 +42,15 @@ let mockPlatformFetchCallIndex = 0; let mockIsManagedMode: (key: string) => boolean = () => false; +let mockCliIpcCallFn: ( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, +) => Promise<{ ok: boolean; result?: unknown; error?: string }> = async () => ({ + ok: false, + error: "IPC unavailable (default mock — forces fallback)", +}); + // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- @@ -127,6 +136,14 @@ mock.module("../../../../security/secure-keys.js", () => ({ _resetBackend: () => {}, })); +mock.module("../../../../ipc/cli-client.js", () => ({ + cliIpcCall: ( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ) => mockCliIpcCallFn(method, params, opts), +})); + mock.module("../../../lib/daemon-credential-client.js", () => ({ deleteSecureKeyViaDaemon: async () => "not-found" as const, setSecureKeyViaDaemon: async () => false, @@ -254,6 +271,7 @@ describe("assistant oauth connect", () => { mockPlatformFetchResults = []; mockPlatformFetchCallIndex = 0; mockIsManagedMode = () => false; + mockCliIpcCallFn = async () => ({ ok: false, error: "IPC unavailable" }); process.exitCode = 0; }); @@ -724,6 +742,195 @@ describe("assistant oauth connect", () => { expect(parsed.error).toContain("--field"); }); + // ------------------------------------------------------------------------- + // IPC-first path (daemon-orchestrated) + // ------------------------------------------------------------------------- + + describe("IPC-first path (BYO mode via daemon)", () => { + beforeEach(() => { + // Set up a valid BYO provider and app for all IPC tests + mockGetProvider = () => ({ + provider: "google", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenExchangeUrl: "https://oauth2.googleapis.com/token", + tokenExchangeBodyFormat: "form", + managedServiceConfigKey: null, + }); + mockIsManagedMode = () => false; + mockGetMostRecentAppByProvider = () => ({ + id: "app-1", + clientId: "ipc-client-id", + clientSecretCredentialPath: "oauth_app/app-1/client_secret", + provider: "google", + createdAt: 0, + updatedAt: 0, + }); + }); + + test("IPC start succeeds + polling returns complete → exits 0 with success output", async () => { + let pollCallCount = 0; + mockCliIpcCallFn = async (method) => { + if (method === "internal/oauth/connect/start") { + return { + ok: true, + result: { + auth_url: "https://accounts.google.com/o/oauth2/auth?state=ipc-state", + state: "ipc-state", + }, + }; + } + if (method.startsWith("internal/oauth/connect/status/")) { + pollCallCount++; + return { + ok: true, + result: { + status: "complete", + service: "google", + account_info: "user@example.com", + }, + }; + } + return { ok: false, error: "unexpected method" }; + }; + + const { exitCode, stdout } = await runCommand([ + "connect", + "google", + "--json", + ]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.ok).toBe(true); + expect(parsed.accountInfo).toBe("user@example.com"); + expect(mockOpenInBrowserCalls.length).toBe(1); + expect(mockOpenInBrowserCalls[0]).toBe( + "https://accounts.google.com/o/oauth2/auth?state=ipc-state", + ); + expect(pollCallCount).toBeGreaterThanOrEqual(1); + }); + + test("IPC start succeeds + polling returns error → exits 1 with error message", async () => { + mockCliIpcCallFn = async (method) => { + if (method === "internal/oauth/connect/start") { + return { + ok: true, + result: { + auth_url: "https://accounts.google.com/o/oauth2/auth?state=ipc-state", + state: "ipc-state", + }, + }; + } + if (method.startsWith("internal/oauth/connect/status/")) { + return { + ok: true, + result: { + status: "error", + service: "google", + error: "exchange failed", + }, + }; + } + return { ok: false, error: "unexpected method" }; + }; + + const { exitCode, stdout } = await runCommand([ + "connect", + "google", + "--json", + ]); + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout); + expect(parsed.ok).toBe(false); + expect(parsed.error).toBe("exchange failed"); + }); + + test("IPC start + --no-browser + json → returns deferred JSON without polling status", async () => { + let statusCallCount = 0; + mockCliIpcCallFn = async (method) => { + if (method === "internal/oauth/connect/start") { + return { + ok: true, + result: { + auth_url: "https://accounts.google.com/o/oauth2/auth?state=ipc-state", + state: "ipc-state", + }, + }; + } + if (method.startsWith("internal/oauth/connect/status/")) { + statusCallCount++; + } + return { ok: false, error: "unexpected method" }; + }; + + const { exitCode, stdout } = await runCommand([ + "connect", + "google", + "--no-browser", + "--json", + ]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.ok).toBe(true); + expect(parsed.deferred).toBe(true); + expect(parsed.authUrl).toBe("https://accounts.google.com/o/oauth2/auth?state=ipc-state"); + expect(parsed.state).toBe("ipc-state"); + expect(parsed.service).toBe("google"); + // Should NOT poll status when --no-browser is set + expect(statusCallCount).toBe(0); + // Should NOT open browser + expect(mockOpenInBrowserCalls.length).toBe(0); + }); + + test("IPC start + --no-browser without json → prints URL to stdout", async () => { + mockCliIpcCallFn = async (method) => { + if (method === "internal/oauth/connect/start") { + return { + ok: true, + result: { + auth_url: "https://accounts.google.com/o/oauth2/auth?state=ipc-state", + state: "ipc-state", + }, + }; + } + return { ok: false, error: "unexpected method" }; + }; + + const { exitCode, stdout } = await runCommand([ + "connect", + "google", + "--no-browser", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("https://accounts.google.com/o/oauth2/auth?state=ipc-state"); + expect(mockOpenInBrowserCalls.length).toBe(0); + }); + + test("IPC returns ok:false → falls back to in-process orchestrateOAuthConnect", async () => { + // Default mockCliIpcCallFn already returns ok: false + let orchestratorCalled = false; + mockOrchestrateOAuthConnect = async () => { + orchestratorCalled = true; + return { + success: true, + deferred: false, + grantedScopes: ["email"], + accountInfo: "fallback@example.com", + }; + }; + + const { exitCode, stdout } = await runCommand([ + "connect", + "google", + "--json", + ]); + expect(exitCode).toBe(0); + expect(orchestratorCalled).toBe(true); + const parsed = JSON.parse(stdout); + expect(parsed.ok).toBe(true); + expect(parsed.accountInfo).toBe("fallback@example.com"); + }); + }); + // ------------------------------------------------------------------------- // Orchestrator error propagation // ------------------------------------------------------------------------- diff --git a/assistant/src/cli/commands/oauth/connect.ts b/assistant/src/cli/commands/oauth/connect.ts index 98bd42ed462..73e8c47a977 100644 --- a/assistant/src/cli/commands/oauth/connect.ts +++ b/assistant/src/cli/commands/oauth/connect.ts @@ -12,6 +12,7 @@ import { import { renderOAuthCompletionPage } from "../../../security/oauth-completion-page.js"; import { getSecureKeyAsync } from "../../../security/secure-keys.js"; import { openInHostBrowser } from "../../../util/browser.js"; +import { cliIpcCall } from "../../../ipc/cli-client.js"; import { getCliLogger } from "../../logger.js"; import { shouldOutputJson, writeOutput } from "../../output.js"; import { @@ -69,6 +70,35 @@ function startManagedRedirectServer(provider: string): Promise<{ }); } +// --------------------------------------------------------------------------- +// IPC polling helpers +// --------------------------------------------------------------------------- + +type OAuthConnectStatusResponse = + | { status: "pending"; service: string } + | { status: "complete"; service: string; account_info?: string } + | { status: "error"; service: string; error?: string }; + +async function pollOAuthConnectStatus( + state: string, + opts: { intervalMs: number; timeoutMs: number }, +): Promise { + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const r = await cliIpcCall( + `internal/oauth/connect/status/${encodeURIComponent(state)}`, + ); + if (r.ok && r.result) { + const { status } = r.result; + if (status === "complete" || status === "error") { + return r.result; + } + } + await new Promise((res) => setTimeout(res, opts.intervalMs)); + } + return { status: "error", service: "?", error: "Timed out waiting for OAuth callback" }; +} + // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- @@ -392,7 +422,72 @@ Examples: } } - // e. Call the orchestrator + // e. Try daemon-orchestrated path first (fixes heap-split for gateway transport). + const startResult = await cliIpcCall<{ auth_url: string; state: string }>( + "internal/oauth/connect/start", + { + service: provider, + clientId, + ...(clientSecret !== undefined ? { clientSecret } : {}), + callbackTransport: opts.callbackTransport, + ...(opts.scopes ? { requestedScopes: opts.scopes } : {}), + }, + ); + + if (startResult.ok && startResult.result?.auth_url) { + const { auth_url, state } = startResult.result; + + if (opts.browser !== false) { + await openInHostBrowser(auth_url); + + log.info("Waiting for authorization in browser... (press Ctrl+C to cancel)"); + const final = await pollOAuthConnectStatus(state, { + intervalMs: 2000, + timeoutMs: 150_000, // matches existing OAuth timeout in managed path + }); + + if (final.status === "complete") { + if (jsonMode) { + writeOutput(cmd, { + ok: true, + grantedScopes: [], + accountInfo: final.account_info, + }); + } else { + process.stdout.write( + `Connected to ${provider}${final.account_info ? ` as ${final.account_info}` : ""}\n`, + ); + } + return; + } + + // status === "error" (includes timeout sentinel) + writeError(final.error ?? "OAuth connect failed"); + return; + } else { + // --no-browser: return the URL immediately, matching existing deferred behavior. + if (jsonMode) { + writeOutput(cmd, { + ok: true, + deferred: true, + authUrl: auth_url, + state, + service: provider, + }); + } else { + process.stdout.write( + `\nAuthorize with ${provider}:\n\n${auth_url}\n\nThe connection will complete automatically once you authorize.\n`, + ); + } + return; + } + } + + // IPC unavailable (daemon unreachable, older daemon without this route, socket missing). + // Fall through to the existing in-process flow. This still carries the heap-split bug + // for gateway transport, but if the daemon is unreachable we have a worse problem; + // the fallback preserves existing behavior as a regression guard. + // e. Call the orchestrator (in-process fallback) const result = await orchestrateOAuthConnect({ service: provider, clientId, diff --git a/assistant/src/oauth/__tests__/oauth-connect-state.test.ts b/assistant/src/oauth/__tests__/oauth-connect-state.test.ts new file mode 100644 index 00000000000..3a0d3d9bcbe --- /dev/null +++ b/assistant/src/oauth/__tests__/oauth-connect-state.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { + _clearAllOAuthConnectStates, + clearExpiredOAuthConnectStates, + getOAuthConnectState, + setOAuthConnectComplete, + setOAuthConnectError, + setOAuthConnectPending, +} from "../oauth-connect-state.js"; + +describe("oauth-connect-state", () => { + beforeEach(() => { + _clearAllOAuthConnectStates(); + }); + + test("setOAuthConnectPending → getOAuthConnectState returns pending", () => { + setOAuthConnectPending("state-1", "google"); + const result = getOAuthConnectState("state-1"); + expect(result).toMatchObject({ status: "pending", service: "google" }); + }); + + test("setOAuthConnectComplete without accountInfo → returns complete", () => { + setOAuthConnectComplete("state-1", "google"); + const result = getOAuthConnectState("state-1"); + expect(result).toMatchObject({ status: "complete", service: "google" }); + }); + + test("setOAuthConnectComplete with accountInfo → returns complete with accountInfo", () => { + setOAuthConnectComplete("state-1", "google", "user@example.com"); + const result = getOAuthConnectState("state-1"); + expect(result).toMatchObject({ + status: "complete", + service: "google", + accountInfo: "user@example.com", + }); + }); + + test("setOAuthConnectError → returns error with message", () => { + setOAuthConnectError("state-1", "google", "token exchange failed"); + const result = getOAuthConnectState("state-1"); + expect(result).toMatchObject({ + status: "error", + service: "google", + error: "token exchange failed", + }); + }); + + test("re-setting same state token overwrites previous", () => { + setOAuthConnectPending("state-1", "google"); + setOAuthConnectComplete("state-1", "google", "user@example.com"); + const result = getOAuthConnectState("state-1"); + expect(result?.status).toBe("complete"); + }); + + test("getOAuthConnectState returns null for unknown state", () => { + expect(getOAuthConnectState("nonexistent")).toBeNull(); + }); + + test("_clearAllOAuthConnectStates removes all entries", () => { + setOAuthConnectPending("state-1", "google"); + setOAuthConnectPending("state-2", "github"); + _clearAllOAuthConnectStates(); + expect(getOAuthConnectState("state-1")).toBeNull(); + expect(getOAuthConnectState("state-2")).toBeNull(); + }); + + test("clearExpiredOAuthConnectStates removes expired pending entries", () => { + setOAuthConnectPending("state-1", "google"); + // Advance Date.now by 6 minutes past PENDING_TTL_MS (5 min) + const originalNow = Date.now; + Date.now = () => originalNow() + 6 * 60 * 1000; + clearExpiredOAuthConnectStates(); + Date.now = originalNow; + expect(getOAuthConnectState("state-1")).toBeNull(); + }); + + test("clearExpiredOAuthConnectStates removes expired complete entries (past 60s grace)", () => { + setOAuthConnectComplete("state-1", "google"); + const originalNow = Date.now; + Date.now = () => originalNow() + 2 * 60 * 1000; // advance 2 minutes past 60s grace + clearExpiredOAuthConnectStates(); + Date.now = originalNow; + expect(getOAuthConnectState("state-1")).toBeNull(); + }); + + test("clearExpiredOAuthConnectStates does not remove non-expired pending entries", () => { + setOAuthConnectPending("state-1", "google"); + clearExpiredOAuthConnectStates(); // called without advancing time + expect(getOAuthConnectState("state-1")).not.toBeNull(); + }); +}); diff --git a/assistant/src/oauth/oauth-connect-state.ts b/assistant/src/oauth/oauth-connect-state.ts new file mode 100644 index 00000000000..e461e1f23f7 --- /dev/null +++ b/assistant/src/oauth/oauth-connect-state.ts @@ -0,0 +1,72 @@ +/** + * In-memory OAuth connect flow status map. + * + * Tracks the current state of daemon-owned OAuth connect flows so the CLI + * can poll for completion via the IPC route. + */ +type OAuthConnectState = + | { status: "pending"; service: string; expiresAt: number } + | { status: "complete"; service: string; accountInfo?: string; completedAt: number } + | { status: "error"; service: string; error: string; failedAt: number }; + +const activeOAuthConnectFlows = new Map(); + +const PENDING_TTL_MS = 5 * 60 * 1000; // 5 min — matches oauth-callback-registry.ts:14 +const COMPLETION_GRACE_MS = 60 * 1000; // 60s so the polling CLI gets one final read + +export function setOAuthConnectPending(state: string, service: string): void { + activeOAuthConnectFlows.set(state, { + status: "pending", + service, + expiresAt: Date.now() + PENDING_TTL_MS, + }); +} + +export function setOAuthConnectComplete( + state: string, + service: string, + accountInfo?: string, +): void { + activeOAuthConnectFlows.set(state, { + status: "complete", + service, + accountInfo, + completedAt: Date.now(), + }); +} + +export function setOAuthConnectError( + state: string, + service: string, + error: string, +): void { + activeOAuthConnectFlows.set(state, { + status: "error", + service, + error, + failedAt: Date.now(), + }); +} + +export function getOAuthConnectState(state: string): OAuthConnectState | null { + clearExpiredOAuthConnectStates(); + return activeOAuthConnectFlows.get(state) ?? null; +} + +export function clearExpiredOAuthConnectStates(): void { + const now = Date.now(); + for (const [key, state] of activeOAuthConnectFlows) { + if (state.status === "pending" && now > state.expiresAt) { + activeOAuthConnectFlows.delete(key); + } else if (state.status === "complete" && now > state.completedAt + COMPLETION_GRACE_MS) { + activeOAuthConnectFlows.delete(key); + } else if (state.status === "error" && now > state.failedAt + COMPLETION_GRACE_MS) { + activeOAuthConnectFlows.delete(key); + } + } +} + +/** Test-only helper — clears all state for test isolation. */ +export function _clearAllOAuthConnectStates(): void { + activeOAuthConnectFlows.clear(); +} diff --git a/assistant/src/runtime/auth/route-policy.ts b/assistant/src/runtime/auth/route-policy.ts index 46c0a442a6e..dec0981fb13 100644 --- a/assistant/src/runtime/auth/route-policy.ts +++ b/assistant/src/runtime/auth/route-policy.ts @@ -588,6 +588,8 @@ const INTERNAL_ENDPOINTS = [ "internal/mcp/auth/start", "internal/mcp/auth/status", "internal/mcp/reload", // ← new + "internal/oauth/connect/start", + "internal/oauth/connect/status", ]; for (const endpoint of INTERNAL_ENDPOINTS) { registerPolicy(endpoint, { diff --git a/assistant/src/runtime/routes/index.ts b/assistant/src/runtime/routes/index.ts index 94552fe8ee9..e927b975b78 100644 --- a/assistant/src/runtime/routes/index.ts +++ b/assistant/src/runtime/routes/index.ts @@ -68,6 +68,7 @@ import { ROUTES as INTERNAL_TWILIO_ROUTES } from "./internal-twilio-routes.js"; import { ROUTES as LLM_CALL_SITES_ROUTES } from "./llm-call-sites-routes.js"; import { ROUTES as LOG_EXPORT_ROUTES } from "./log-export-routes.js"; import { ROUTES as MCP_AUTH_ROUTES } from "./mcp-auth-routes.js"; +import { ROUTES as OAUTH_CONNECT_ROUTES } from "./oauth-connect-routes.js"; import { ROUTES as MEMORY_ITEM_ROUTES } from "./memory-item-routes.js"; import { ROUTES as MEMORY_V2_ROUTES } from "./memory-v2-routes.js"; import { ROUTES as MIGRATION_ROLLBACK_ROUTES } from "./migration-rollback-routes.js"; @@ -159,6 +160,7 @@ export const ROUTES: RouteDefinition[] = [ ...INTERFACE_ROUTES, ...INTERNAL_OAUTH_ROUTES, ...MCP_AUTH_ROUTES, + ...OAUTH_CONNECT_ROUTES, ...INTERNAL_TWILIO_ROUTES, ...LOG_EXPORT_ROUTES, ...LLM_CALL_SITES_ROUTES, diff --git a/assistant/src/runtime/routes/oauth-connect-routes.ts b/assistant/src/runtime/routes/oauth-connect-routes.ts new file mode 100644 index 00000000000..87d9551d5b5 --- /dev/null +++ b/assistant/src/runtime/routes/oauth-connect-routes.ts @@ -0,0 +1,147 @@ +/** + * Internal routes for daemon-owned OAuth connect flows (CLI gateway transport fix). + * + * POST internal/oauth/connect/start — starts the flow in the daemon, returns auth URL + * GET internal/oauth/connect/status/:state — polls current flow status + */ + +import { z } from "zod"; + +import { orchestrateOAuthConnect } from "../../oauth/connect-orchestrator.js"; +import { + getOAuthConnectState, + setOAuthConnectComplete, + setOAuthConnectError, + setOAuthConnectPending, +} from "../../oauth/oauth-connect-state.js"; +import { getLogger } from "../../util/logger.js"; +import { BadRequestError, InternalError, NotFoundError } from "./errors.js"; +import type { RouteDefinition } from "./types.js"; + +const log = getLogger("oauth-connect-routes"); + +async function handleOAuthConnectStart({ + body, +}: { + body?: Record; +}): Promise<{ auth_url: string; state: string }> { + const { + service, + clientId, + clientSecret, + callbackTransport, + requestedScopes, + } = (body ?? {}) as { + service: string; + clientId: string; + clientSecret?: string; + callbackTransport?: string; + requestedScopes?: string[]; + }; + + if (!service) throw new BadRequestError("service is required"); + if (!clientId) throw new BadRequestError("clientId is required"); + if (callbackTransport !== "loopback" && callbackTransport !== "gateway") { + throw new BadRequestError( + 'callbackTransport must be "loopback" or "gateway"', + ); + } + + // Capture resolvedState separately so the onDeferredComplete closure can + // reference it without a reference-before-assignment risk (the fire-and-forget + // tail only fires after the await resolves, by which point resolvedState is set). + let resolvedState: string | undefined; + + let result: Awaited>; + try { + result = await orchestrateOAuthConnect({ + service, + clientId, + clientSecret, + callbackTransport, + ...(requestedScopes ? { requestedScopes } : {}), + isInteractive: false, + onDeferredComplete: (r) => { + if (!resolvedState) return; + if (r.success) { + setOAuthConnectComplete(resolvedState, r.service, r.accountInfo); + } else { + setOAuthConnectError(resolvedState, r.service, r.error ?? "OAuth connect failed"); + } + }, + }); + } catch (err) { + throw new InternalError(err instanceof Error ? err.message : String(err)); + } + + if (!result.success || !result.deferred) { + throw new InternalError(result.error ?? "Orchestrator returned non-deferred result"); + } + + resolvedState = result.state; + setOAuthConnectPending(result.state, service); + log.info({ state: result.state, service }, "oauth connect flow started"); + return { auth_url: result.authorizeUrl, state: result.state }; +} + +function handleOAuthConnectStatus({ + pathParams, +}: { + pathParams?: Record; +}): { + status: "pending" | "complete" | "error"; + service: string; + account_info?: string; + error?: string; +} { + const { state } = pathParams as { state: string }; + const flowState = getOAuthConnectState(state); + + if (flowState === null) { + throw new NotFoundError(`No active OAuth connect flow for state "${state}"`); + } + + if (flowState.status === "pending") return { status: "pending", service: flowState.service }; + if (flowState.status === "complete") { + return { + status: "complete", + service: flowState.service, + ...(flowState.accountInfo ? { account_info: flowState.accountInfo } : {}), + }; + } + return { status: "error", service: flowState.service, error: flowState.error }; +} + +export const ROUTES: RouteDefinition[] = [ + { + operationId: "internal_oauth_connect_start", + endpoint: "internal/oauth/connect/start", + method: "POST", + summary: "Start daemon-owned OAuth connect flow", + description: + "Starts an OAuth connect flow in the daemon and returns the authorization URL for the CLI to open in the browser.", + tags: ["internal"], + requestBody: z.object({ + service: z.string(), + clientId: z.string(), + clientSecret: z.string().optional(), + callbackTransport: z.enum(["loopback", "gateway"]), + requestedScopes: z.array(z.string()).optional(), + }), + handler: handleOAuthConnectStart, + }, + { + operationId: "internal_oauth_connect_status", + endpoint: "internal/oauth/connect/status/:state", + method: "GET", + summary: "Poll daemon OAuth connect flow status", + description: + "Returns the current status of an in-flight daemon-owned OAuth connect flow (pending/complete/error).", + tags: ["internal"], + pathParams: [{ name: "state" }], + additionalResponses: { + "404": { description: "No active OAuth connect flow for the given state token" }, + }, + handler: handleOAuthConnectStatus, + }, +]; From 0bbbdf7b0fd53c9ccae88fec9ccc50e9c171c3b0 Mon Sep 17 00:00:00 2001 From: "credence-the-bot[bot]" <183148327+credence-the-bot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 03:55:58 +0000 Subject: [PATCH 2/5] fix(oauth-connect): pass lint, type-check, and openapi-check - Sort imports per simple-import-sort/imports rule (autofix) - Add eslint-disable for the intentionally-let resolvedState binding - Narrow OAuthConnectResult union before reading .error (split into two if branches) - Narrow OAuthConnectStatusResponse before reading .error in CLI poll loop - Regenerate openapi.yaml to include the 2 new internal routes --- assistant/openapi.yaml | 55 +++++++++++++++++++ assistant/src/cli/commands/oauth/connect.ts | 13 ++++- assistant/src/runtime/routes/index.ts | 2 +- .../runtime/routes/oauth-connect-routes.ts | 8 ++- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/assistant/openapi.yaml b/assistant/openapi.yaml index 93d758de4b7..426afe10aec 100644 --- a/assistant/openapi.yaml +++ b/assistant/openapi.yaml @@ -6795,6 +6795,61 @@ paths: required: - state additionalProperties: false + /v1/internal/oauth/connect/start: + post: + operationId: internal_oauth_connect_start_post + summary: Start daemon-owned OAuth connect flow + description: Starts an OAuth connect flow in the daemon and returns the authorization URL for the CLI to open in the browser. + tags: + - internal + responses: + "200": + description: Successful response + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + service: + type: string + clientId: + type: string + clientSecret: + type: string + callbackTransport: + type: string + enum: + - loopback + - gateway + requestedScopes: + type: array + items: + type: string + required: + - service + - clientId + - callbackTransport + additionalProperties: false + /v1/internal/oauth/connect/status/{state}: + get: + operationId: internal_oauth_connect_status_by_state_get + summary: Poll daemon OAuth connect flow status + description: Returns the current status of an in-flight daemon-owned OAuth connect flow (pending/complete/error). + tags: + - internal + responses: + "200": + description: Successful response + "404": + description: No active OAuth connect flow for the given state token + parameters: + - name: state + in: path + required: true + schema: + type: string /v1/internal/twilio/connect-action: post: operationId: internal_twilio_connectaction_post diff --git a/assistant/src/cli/commands/oauth/connect.ts b/assistant/src/cli/commands/oauth/connect.ts index 73e8c47a977..d9379b8028e 100644 --- a/assistant/src/cli/commands/oauth/connect.ts +++ b/assistant/src/cli/commands/oauth/connect.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from "node:http"; import type { Command } from "commander"; import { getIsContainerized } from "../../../config/env-registry.js"; +import { cliIpcCall } from "../../../ipc/cli-client.js"; import { orchestrateOAuthConnect } from "../../../oauth/connect-orchestrator.js"; import { getAppByProviderAndClientId, @@ -12,7 +13,6 @@ import { import { renderOAuthCompletionPage } from "../../../security/oauth-completion-page.js"; import { getSecureKeyAsync } from "../../../security/secure-keys.js"; import { openInHostBrowser } from "../../../util/browser.js"; -import { cliIpcCall } from "../../../ipc/cli-client.js"; import { getCliLogger } from "../../logger.js"; import { shouldOutputJson, writeOutput } from "../../output.js"; import { @@ -461,8 +461,15 @@ Examples: return; } - // status === "error" (includes timeout sentinel) - writeError(final.error ?? "OAuth connect failed"); + if (final.status === "error") { + // Includes the timeout sentinel emitted by pollOAuthConnectStatus. + writeError(final.error ?? "OAuth connect failed"); + return; + } + + // Defensive: pollOAuthConnectStatus should never return pending, + // but TS narrowing requires us to handle it. + writeError("OAuth connect ended in an unexpected pending state"); return; } else { // --no-browser: return the URL immediately, matching existing deferred behavior. diff --git a/assistant/src/runtime/routes/index.ts b/assistant/src/runtime/routes/index.ts index e927b975b78..b77d2e98236 100644 --- a/assistant/src/runtime/routes/index.ts +++ b/assistant/src/runtime/routes/index.ts @@ -68,13 +68,13 @@ import { ROUTES as INTERNAL_TWILIO_ROUTES } from "./internal-twilio-routes.js"; import { ROUTES as LLM_CALL_SITES_ROUTES } from "./llm-call-sites-routes.js"; import { ROUTES as LOG_EXPORT_ROUTES } from "./log-export-routes.js"; import { ROUTES as MCP_AUTH_ROUTES } from "./mcp-auth-routes.js"; -import { ROUTES as OAUTH_CONNECT_ROUTES } from "./oauth-connect-routes.js"; import { ROUTES as MEMORY_ITEM_ROUTES } from "./memory-item-routes.js"; import { ROUTES as MEMORY_V2_ROUTES } from "./memory-v2-routes.js"; import { ROUTES as MIGRATION_ROLLBACK_ROUTES } from "./migration-rollback-routes.js"; import { ROUTES as MIGRATION_ROUTES } from "./migration-routes.js"; import { ROUTES as NOTIFICATION_ROUTES } from "./notification-routes.js"; import { ROUTES as OAUTH_APPS_ROUTES } from "./oauth-apps.js"; +import { ROUTES as OAUTH_CONNECT_ROUTES } from "./oauth-connect-routes.js"; import { ROUTES as OAUTH_PROVIDERS_ROUTES } from "./oauth-providers.js"; import { ROUTES as PLAYGROUND_ROUTES } from "./playground/index.js"; import { ROUTES as PROFILER_ROUTES } from "./profiler-routes.js"; diff --git a/assistant/src/runtime/routes/oauth-connect-routes.ts b/assistant/src/runtime/routes/oauth-connect-routes.ts index 87d9551d5b5..61110e5fedf 100644 --- a/assistant/src/runtime/routes/oauth-connect-routes.ts +++ b/assistant/src/runtime/routes/oauth-connect-routes.ts @@ -50,6 +50,7 @@ async function handleOAuthConnectStart({ // Capture resolvedState separately so the onDeferredComplete closure can // reference it without a reference-before-assignment risk (the fire-and-forget // tail only fires after the await resolves, by which point resolvedState is set). + // eslint-disable-next-line prefer-const -- intentional forward-declared binding let resolvedState: string | undefined; let result: Awaited>; @@ -74,8 +75,11 @@ async function handleOAuthConnectStart({ throw new InternalError(err instanceof Error ? err.message : String(err)); } - if (!result.success || !result.deferred) { - throw new InternalError(result.error ?? "Orchestrator returned non-deferred result"); + if (!result.success) { + throw new InternalError(result.error); + } + if (!result.deferred) { + throw new InternalError("Orchestrator returned non-deferred result"); } resolvedState = result.state; From 7c911f220e47ef45c463748b8763f23d432b815e Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Tue, 5 May 2026 04:02:30 +0000 Subject: [PATCH 3/5] fix: use operationId and structured params for cliIpcCall (Devin/Codex P1 review) Co-Authored-By: Claude Sonnet 4.6 --- .../commands/oauth/__tests__/connect.test.ts | 14 +++++++------- assistant/src/cli/commands/oauth/connect.ts | 17 ++++++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts b/assistant/src/cli/commands/oauth/__tests__/connect.test.ts index a1abb321908..00f9fab2dfc 100644 --- a/assistant/src/cli/commands/oauth/__tests__/connect.test.ts +++ b/assistant/src/cli/commands/oauth/__tests__/connect.test.ts @@ -770,7 +770,7 @@ describe("assistant oauth connect", () => { test("IPC start succeeds + polling returns complete → exits 0 with success output", async () => { let pollCallCount = 0; mockCliIpcCallFn = async (method) => { - if (method === "internal/oauth/connect/start") { + if (method === "internal_oauth_connect_start") { return { ok: true, result: { @@ -779,7 +779,7 @@ describe("assistant oauth connect", () => { }, }; } - if (method.startsWith("internal/oauth/connect/status/")) { + if (method === "internal_oauth_connect_status") { pollCallCount++; return { ok: true, @@ -811,7 +811,7 @@ describe("assistant oauth connect", () => { test("IPC start succeeds + polling returns error → exits 1 with error message", async () => { mockCliIpcCallFn = async (method) => { - if (method === "internal/oauth/connect/start") { + if (method === "internal_oauth_connect_start") { return { ok: true, result: { @@ -820,7 +820,7 @@ describe("assistant oauth connect", () => { }, }; } - if (method.startsWith("internal/oauth/connect/status/")) { + if (method === "internal_oauth_connect_status") { return { ok: true, result: { @@ -847,7 +847,7 @@ describe("assistant oauth connect", () => { test("IPC start + --no-browser + json → returns deferred JSON without polling status", async () => { let statusCallCount = 0; mockCliIpcCallFn = async (method) => { - if (method === "internal/oauth/connect/start") { + if (method === "internal_oauth_connect_start") { return { ok: true, result: { @@ -856,7 +856,7 @@ describe("assistant oauth connect", () => { }, }; } - if (method.startsWith("internal/oauth/connect/status/")) { + if (method === "internal_oauth_connect_status") { statusCallCount++; } return { ok: false, error: "unexpected method" }; @@ -883,7 +883,7 @@ describe("assistant oauth connect", () => { test("IPC start + --no-browser without json → prints URL to stdout", async () => { mockCliIpcCallFn = async (method) => { - if (method === "internal/oauth/connect/start") { + if (method === "internal_oauth_connect_start") { return { ok: true, result: { diff --git a/assistant/src/cli/commands/oauth/connect.ts b/assistant/src/cli/commands/oauth/connect.ts index d9379b8028e..d6c35577ab2 100644 --- a/assistant/src/cli/commands/oauth/connect.ts +++ b/assistant/src/cli/commands/oauth/connect.ts @@ -86,7 +86,8 @@ async function pollOAuthConnectStatus( const deadline = Date.now() + opts.timeoutMs; while (Date.now() < deadline) { const r = await cliIpcCall( - `internal/oauth/connect/status/${encodeURIComponent(state)}`, + "internal_oauth_connect_status", + { pathParams: { state } }, ); if (r.ok && r.result) { const { status } = r.result; @@ -424,13 +425,15 @@ Examples: // e. Try daemon-orchestrated path first (fixes heap-split for gateway transport). const startResult = await cliIpcCall<{ auth_url: string; state: string }>( - "internal/oauth/connect/start", + "internal_oauth_connect_start", { - service: provider, - clientId, - ...(clientSecret !== undefined ? { clientSecret } : {}), - callbackTransport: opts.callbackTransport, - ...(opts.scopes ? { requestedScopes: opts.scopes } : {}), + body: { + service: provider, + clientId, + ...(clientSecret !== undefined ? { clientSecret } : {}), + callbackTransport: opts.callbackTransport, + ...(opts.scopes ? { requestedScopes: opts.scopes } : {}), + }, }, ); From d3c324b8eb24f55215bae6abdd1b7dc2d1468c2b Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Tue, 5 May 2026 04:13:04 +0000 Subject: [PATCH 4/5] fix: thread grantedScopes through IPC path to avoid empty-scopes inconsistency (Devin review) Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/oauth-connect-routes.test.ts | 27 +++++++++++++++++++ assistant/src/cli/commands/oauth/connect.ts | 4 +-- .../__tests__/oauth-connect-state.test.ts | 11 ++++++++ assistant/src/oauth/connect-orchestrator.ts | 2 ++ assistant/src/oauth/oauth-connect-state.ts | 4 ++- .../runtime/routes/oauth-connect-routes.ts | 4 ++- 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/assistant/src/__tests__/oauth-connect-routes.test.ts b/assistant/src/__tests__/oauth-connect-routes.test.ts index c3bb0ec176d..939dfb9fcc6 100644 --- a/assistant/src/__tests__/oauth-connect-routes.test.ts +++ b/assistant/src/__tests__/oauth-connect-routes.test.ts @@ -13,6 +13,7 @@ type OrchestrateOptions = { success: boolean; service: string; accountInfo?: string; + grantedScopes?: string[]; error?: string; }) => void; }; @@ -244,6 +245,32 @@ describe("oauth-connect-routes", () => { ).toThrow(NotFoundError); }); + test("returns complete with granted_scopes after onDeferredComplete fires with grantedScopes", async () => { + await findRoute("internal_oauth_connect_start").handler({ + body: { + service: "google", + clientId: "my-client-id", + callbackTransport: "gateway", + }, + }); + // Fire the onDeferredComplete callback with grantedScopes + capturedOnDeferredComplete?.({ + success: true, + service: "google", + accountInfo: "user@example.com", + grantedScopes: ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/gmail.readonly"], + }); + const result = findRoute("internal_oauth_connect_status").handler({ + pathParams: { state: "test-state-uuid-abc123" }, + }) as Record; + expect(result).toMatchObject({ + status: "complete", + service: "google", + account_info: "user@example.com", + granted_scopes: ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/gmail.readonly"], + }); + }); + test("complete without accountInfo does not include account_info field", async () => { await findRoute("internal_oauth_connect_start").handler({ body: { diff --git a/assistant/src/cli/commands/oauth/connect.ts b/assistant/src/cli/commands/oauth/connect.ts index d6c35577ab2..1e8ee38aa59 100644 --- a/assistant/src/cli/commands/oauth/connect.ts +++ b/assistant/src/cli/commands/oauth/connect.ts @@ -76,7 +76,7 @@ function startManagedRedirectServer(provider: string): Promise<{ type OAuthConnectStatusResponse = | { status: "pending"; service: string } - | { status: "complete"; service: string; account_info?: string } + | { status: "complete"; service: string; account_info?: string; granted_scopes?: string[] } | { status: "error"; service: string; error?: string }; async function pollOAuthConnectStatus( @@ -453,7 +453,7 @@ Examples: if (jsonMode) { writeOutput(cmd, { ok: true, - grantedScopes: [], + grantedScopes: final.granted_scopes ?? [], accountInfo: final.account_info, }); } else { diff --git a/assistant/src/oauth/__tests__/oauth-connect-state.test.ts b/assistant/src/oauth/__tests__/oauth-connect-state.test.ts index 3a0d3d9bcbe..b313536416b 100644 --- a/assistant/src/oauth/__tests__/oauth-connect-state.test.ts +++ b/assistant/src/oauth/__tests__/oauth-connect-state.test.ts @@ -36,6 +36,17 @@ describe("oauth-connect-state", () => { }); }); + test("setOAuthConnectComplete with grantedScopes → returns complete with grantedScopes", () => { + setOAuthConnectComplete("state-1", "google", "user@example.com", ["scope:read", "scope:write"]); + const result = getOAuthConnectState("state-1"); + expect(result).toMatchObject({ + status: "complete", + service: "google", + accountInfo: "user@example.com", + grantedScopes: ["scope:read", "scope:write"], + }); + }); + test("setOAuthConnectError → returns error with message", () => { setOAuthConnectError("state-1", "google", "token exchange failed"); const result = getOAuthConnectState("state-1"); diff --git a/assistant/src/oauth/connect-orchestrator.ts b/assistant/src/oauth/connect-orchestrator.ts index f7916b21344..49a71e7ba91 100644 --- a/assistant/src/oauth/connect-orchestrator.ts +++ b/assistant/src/oauth/connect-orchestrator.ts @@ -81,6 +81,7 @@ export interface OAuthConnectOptions { success: boolean; service: string; accountInfo?: string; + grantedScopes?: string[]; error?: string; }) => void; } @@ -256,6 +257,7 @@ export async function orchestrateOAuthConnect( success: true, service: options.service, accountInfo: stored.accountInfo ?? parsedAccountIdentifier, + grantedScopes: result.grantedScopes, }); } catch (err) { log.error( diff --git a/assistant/src/oauth/oauth-connect-state.ts b/assistant/src/oauth/oauth-connect-state.ts index e461e1f23f7..ed65f6a2133 100644 --- a/assistant/src/oauth/oauth-connect-state.ts +++ b/assistant/src/oauth/oauth-connect-state.ts @@ -6,7 +6,7 @@ */ type OAuthConnectState = | { status: "pending"; service: string; expiresAt: number } - | { status: "complete"; service: string; accountInfo?: string; completedAt: number } + | { status: "complete"; service: string; accountInfo?: string; grantedScopes?: string[]; completedAt: number } | { status: "error"; service: string; error: string; failedAt: number }; const activeOAuthConnectFlows = new Map(); @@ -26,11 +26,13 @@ export function setOAuthConnectComplete( state: string, service: string, accountInfo?: string, + grantedScopes?: string[], ): void { activeOAuthConnectFlows.set(state, { status: "complete", service, accountInfo, + grantedScopes, completedAt: Date.now(), }); } diff --git a/assistant/src/runtime/routes/oauth-connect-routes.ts b/assistant/src/runtime/routes/oauth-connect-routes.ts index 61110e5fedf..93b61022b8e 100644 --- a/assistant/src/runtime/routes/oauth-connect-routes.ts +++ b/assistant/src/runtime/routes/oauth-connect-routes.ts @@ -65,7 +65,7 @@ async function handleOAuthConnectStart({ onDeferredComplete: (r) => { if (!resolvedState) return; if (r.success) { - setOAuthConnectComplete(resolvedState, r.service, r.accountInfo); + setOAuthConnectComplete(resolvedState, r.service, r.accountInfo, r.grantedScopes); } else { setOAuthConnectError(resolvedState, r.service, r.error ?? "OAuth connect failed"); } @@ -96,6 +96,7 @@ function handleOAuthConnectStatus({ status: "pending" | "complete" | "error"; service: string; account_info?: string; + granted_scopes?: string[]; error?: string; } { const { state } = pathParams as { state: string }; @@ -111,6 +112,7 @@ function handleOAuthConnectStatus({ status: "complete", service: flowState.service, ...(flowState.accountInfo ? { account_info: flowState.accountInfo } : {}), + ...(flowState.grantedScopes ? { granted_scopes: flowState.grantedScopes } : {}), }; } return { status: "error", service: flowState.service, error: flowState.error }; From 5138bfdda93e6b123c36651fe75d949b058d280d Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Tue, 5 May 2026 04:14:07 +0000 Subject: [PATCH 5/5] fix(oauth): align IPC poll timeout with 5-min loopback OAuth window (Codex review) Co-Authored-By: Claude Sonnet 4.6 --- assistant/src/cli/commands/oauth/connect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assistant/src/cli/commands/oauth/connect.ts b/assistant/src/cli/commands/oauth/connect.ts index 1e8ee38aa59..ce342467e28 100644 --- a/assistant/src/cli/commands/oauth/connect.ts +++ b/assistant/src/cli/commands/oauth/connect.ts @@ -446,7 +446,7 @@ Examples: log.info("Waiting for authorization in browser... (press Ctrl+C to cancel)"); const final = await pollOAuthConnectStatus(state, { intervalMs: 2000, - timeoutMs: 150_000, // matches existing OAuth timeout in managed path + timeoutMs: 5 * 60 * 1000, // match LOOPBACK_TIMEOUT_MS in oauth2.ts (5 min) }); if (final.status === "complete") {