diff --git a/apps/web/src/components/upstream-edit/CodexConfigPanel.vue b/apps/web/src/components/upstream-edit/CodexConfigPanel.vue index 3a8bd0f61..431ed7226 100644 --- a/apps/web/src/components/upstream-edit/CodexConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/CodexConfigPanel.vue @@ -5,12 +5,15 @@ import type { CodexImportTab, CodexPkceStartResult } from './codex-import-types. import CodexAccountCard from './CodexAccountCard.vue'; import CodexImportTabs from './CodexImportTabs.vue'; import { callApi, useApi } from '../../api/client.ts'; -import type { UpstreamRecord } from '../../api/types.ts'; +import type { ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts'; import { Button, Spinner } from '@floway-dev/ui'; const props = defineProps<{ mode: 'create' | 'edit'; record: UpstreamRecord | null; + // Current edit-form chain; forwarded into refresh-now so a refresh fired + // before saving uses the in-progress chain. + proxyFallbackList: ProxyFallbackEntry[]; }>(); const emit = defineEmits<{ @@ -85,7 +88,10 @@ const submit = async () => { const refreshTokenNow = async () => { refreshing.value = true; const { data, error } = await callApi( - () => api.api.upstreams[':id']['codex-refresh-now'].$post({ param: { id: props.record!.id }, json: {} }), + () => api.api.upstreams[':id']['codex-refresh-now'].$post({ + param: { id: props.record!.id }, + json: { proxy_fallback_list: props.proxyFallbackList }, + }), ); refreshing.value = false; if (error) { emit('error', error.message); return; } diff --git a/apps/web/src/components/upstream-edit/CopilotConfigPanel.vue b/apps/web/src/components/upstream-edit/CopilotConfigPanel.vue index 816fc03fb..f5cf049cd 100644 --- a/apps/web/src/components/upstream-edit/CopilotConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/CopilotConfigPanel.vue @@ -2,12 +2,13 @@ import CopilotDeviceFlow from './CopilotDeviceFlow.vue'; import CopilotInfo from './CopilotInfo.vue'; -import type { CopilotQuotaSnapshot, CopilotUpstreamConfig, UpstreamRecord } from '../../api/types.ts'; +import type { CopilotQuotaSnapshot, CopilotUpstreamConfig, ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts'; defineProps<{ record: UpstreamRecord | null; initialQuota?: CopilotQuotaSnapshot | null; initialQuotaError?: string | null; + proxyFallbackList: ProxyFallbackEntry[]; }>(); defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>(); @@ -21,5 +22,5 @@ defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>(); :initial-quota="initialQuota" :initial-quota-error="initialQuotaError" /> - + diff --git a/apps/web/src/components/upstream-edit/CopilotDeviceFlow.vue b/apps/web/src/components/upstream-edit/CopilotDeviceFlow.vue index f960642ac..fb88b8ee9 100644 --- a/apps/web/src/components/upstream-edit/CopilotDeviceFlow.vue +++ b/apps/web/src/components/upstream-edit/CopilotDeviceFlow.vue @@ -2,9 +2,15 @@ import { onUnmounted, ref } from 'vue'; import { callApi, useApi } from '../../api/client.ts'; -import type { DeviceFlowPoll, DeviceFlowStart, UpstreamRecord } from '../../api/types.ts'; +import type { DeviceFlowPoll, DeviceFlowStart, ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts'; import { Button, Code, Spinner } from '@floway-dev/ui'; +const props = defineProps<{ + // Current edit-form chain; forwarded into the poll body so the + // GitHub-side egress honors the in-progress chain. + proxyFallbackList: ProxyFallbackEntry[]; +}>(); + const emit = defineEmits<{ completed: [upstream: UpstreamRecord | undefined] }>(); const api = useApi(); @@ -26,7 +32,9 @@ const stopPolling = () => { const pollOnce = async (currentInterval: number) => { if (!flow.value) return; const { data, error: err } = await callApi( - () => api.api.upstreams.copilot.auth.poll.$post({ json: { device_code: flow.value!.device_code } }), + () => api.api.upstreams.copilot.auth.poll.$post({ + json: { device_code: flow.value!.device_code, proxy_fallback_list: props.proxyFallbackList }, + }), ); if (err) return; // Transient — keep polling. if (!data) return; diff --git a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue index ad1ca7784..1bde7ce53 100644 --- a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue @@ -157,6 +157,7 @@ onBeforeUnmount(() => floorObserver?.disconnect()); :record="record" :initial-quota="initialCopilotQuota" :initial-quota-error="initialCopilotQuotaError" + :proxy-fallback-list="proxyFallbackList" @completed="u => $emit('copilot-completed', u)" /> @@ -165,6 +166,7 @@ onBeforeUnmount(() => floorObserver?.disconnect()); diff --git a/packages/gateway/src/control-plane/auth/github-device-flow.ts b/packages/gateway/src/control-plane/auth/github-device-flow.ts index d96d89dff..962ba1ee7 100644 --- a/packages/gateway/src/control-plane/auth/github-device-flow.ts +++ b/packages/gateway/src/control-plane/auth/github-device-flow.ts @@ -1,3 +1,4 @@ +import { directFetcher, type Fetcher } from '@floway-dev/provider'; import { githubHeaders, isCopilotAccountType, type CopilotAccountType } from '@floway-dev/provider-copilot'; export interface GitHubUser { @@ -18,8 +19,10 @@ interface GitHubDeviceFlowStart { interval: number; } -export const startGitHubDeviceFlow = async () => { - const resp = await fetch('https://github.com/login/device/code', { +// All GitHub egress accepts a Fetcher so the copilot auth poll can forward +// the operator's edit-form proxy override; absent that, direct egress. +export const startGitHubDeviceFlow = async (fetcher: Fetcher = directFetcher) => { + const resp = await fetcher('https://github.com/login/device/code', { method: 'POST', headers: { 'content-type': 'application/json', @@ -40,8 +43,8 @@ export const startGitHubDeviceFlow = async () => { return { ok: true as const, data }; }; -export const pollGitHubDeviceFlow = async (deviceCode: string) => { - const resp = await fetch('https://github.com/login/oauth/access_token', { +export const pollGitHubDeviceFlow = async (deviceCode: string, fetcher: Fetcher = directFetcher) => { + const resp = await fetcher('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'content-type': 'application/json', @@ -64,8 +67,8 @@ export const pollGitHubDeviceFlow = async (deviceCode: string) => { }; }; -export const fetchGitHubUser = async (githubToken: string) => { - const userResp = await fetch('https://api.github.com/user', { +export const fetchGitHubUser = async (githubToken: string, fetcher: Fetcher = directFetcher) => { + const userResp = await fetcher('https://api.github.com/user', { headers: { authorization: `token ${githubToken}`, accept: 'application/json', @@ -77,8 +80,8 @@ export const fetchGitHubUser = async (githubToken: string) => { return (await userResp.json()) as GitHubUser; }; -export const detectAccountType = async (githubToken: string): Promise => { - const resp = await fetch('https://api.github.com/copilot_internal/user', { +export const detectAccountType = async (githubToken: string, fetcher: Fetcher = directFetcher): Promise => { + const resp = await fetcher('https://api.github.com/copilot_internal/user', { headers: githubHeaders(githubToken), }); if (!resp.ok) throw new Error(`GitHub Copilot account type detection failed: ${resp.status} ${await resp.text()}`); diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index 57496ed6e..b5cb8c000 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -259,6 +259,9 @@ export const fetchModelsBody = z.object({ export const copilotAuthPollBody = z.object({ device_code: z.string().min(1), + // Edit-form override routing every GitHub-side call through the + // operator's in-progress chain. See proxy-resolution.ts. + proxy_fallback_list: proxyFallbackListSchema.optional(), }); // --- codex import / PKCE / refresh --- @@ -307,7 +310,11 @@ export const codexReimportBody = z.object({ ...codexCredentialFields, }).refine(requireExactlyOneCredential, codexCredentialRefineMessage); -export const codexRefreshNowBody = z.object({}); +export const codexRefreshNowBody = z.object({ + // Edit-form override; absent falls back to the persisted row's list. See + // proxy-resolution.ts. + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); // --- proxies --- // diff --git a/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts b/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts new file mode 100644 index 000000000..0ff045335 --- /dev/null +++ b/packages/gateway/src/control-plane/upstreams/proxy-resolution.ts @@ -0,0 +1,70 @@ +import { createFetcher, type ProxyEntry } from '../../dial/fetcher.ts'; +import { createPerRequestFetcherForAdmin } from '../../dial/per-request.ts'; +import { getRepo } from '../../repo/index.ts'; +import { DIRECT_PROXY_ID, normalizeProxyFallbackList } from '../../repo/proxy-fallback-list.ts'; +import { getSocketDial } from '@floway-dev/platform'; +import { directFetcher, type Fetcher, type ProxyFallbackEntry } from '@floway-dev/provider'; +import { parseProxyUri, type ProxyUriError, runProxiedRequest } from '@floway-dev/proxy'; + +// Fetcher resolution for control-plane operations that fire from the +// dashboard edit form, where the in-progress proxy_fallback_list must take +// precedence over whatever is persisted. The override path validates proxy +// ids against the catalog and throws on unknown / malformed entries; the +// persisted path reuses the per-request fetcher bound to the saved row. +export const resolveControlPlaneFetcher = async (opts: { + override?: readonly ProxyFallbackEntry[]; + upstreamId?: string; +}): Promise => { + if (opts.override !== undefined) { + return await buildOverrideFetcher(opts.override, opts.upstreamId ?? 'draft'); + } + if (opts.upstreamId !== undefined) { + return (await createPerRequestFetcherForAdmin())(opts.upstreamId); + } + return directFetcher; +}; + +const buildOverrideFetcher = async (rawList: readonly ProxyFallbackEntry[], upstreamId: string): Promise => { + const list = normalizeProxyFallbackList(rawList); + const referenced = new Set(list.filter(entry => entry.id !== DIRECT_PROXY_ID).map(entry => entry.id)); + if (referenced.size === 0) { + return directFetcher; + } + + const repo = getRepo(); + const proxies = await repo.proxies.list(); + const proxyById = new Map(); + const parseErrors = new Map(); + for (const p of proxies) { + if (!referenced.has(p.id)) continue; + try { + proxyById.set(p.id, { + config: parseProxyUri(p.url), + dialTimeoutMs: p.dialTimeoutSeconds === null ? null : p.dialTimeoutSeconds * 1000, + }); + } catch (err) { + parseErrors.set(p.id, err as ProxyUriError); + } + } + + const unknown = list.find(entry => entry.id !== DIRECT_PROXY_ID && !proxyById.has(entry.id) && !parseErrors.has(entry.id)); + if (unknown !== undefined) { + throw new Error(`unknown proxy id in fallback list: ${unknown.id}`); + } + const bad = list.find(entry => parseErrors.has(entry.id)); + if (bad !== undefined) { + const err = parseErrors.get(bad.id)!; + throw new Error(`malformed proxy ${bad.id}: ${err.message}`); + } + + return createFetcher({ + repo, + upstreamId, + fallbackList: list, + currentColo: null, + proxyById, + runProxied: runProxiedRequest, + runDirect: directFetcher, + socketDial: getSocketDial, + }); +}; diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 47ffc2032..f84d0fd0e 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; import type { z } from 'zod'; +import { resolveControlPlaneFetcher } from './proxy-resolution.ts'; import { upstreamRecordToJson, type SerializedUpstreamRecord } from './serialize.ts'; import { MODEL_LISTING_FAILURE_MESSAGE } from '../../data-plane/models/shared.ts'; import { fetchUpstreamModelsCached } from '../../data-plane/providers/models-cache.ts'; @@ -357,11 +358,15 @@ const copilotConfigUserId = (config: unknown): number | null => { return typeof config.user.id === 'number' && Number.isSafeInteger(config.user.id) ? config.user.id : null; }; +// The body's optional `proxy_fallback_list` is the operator's in-progress +// edit-form override, forwarded into every GitHub-side fetch so the device +// flow lands through the same proxy chain they're configuring. export const copilotAuthPoll = async (c: CtxWithJson) => { try { - const { device_code: deviceCode } = c.req.valid('json'); + const { device_code: deviceCode, proxy_fallback_list: proxyFallbackList } = c.req.valid('json'); + const fetcher = await resolveControlPlaneFetcher({ override: proxyFallbackList }); - const data = await pollGitHubDeviceFlow(deviceCode); + const data = await pollGitHubDeviceFlow(deviceCode, fetcher); if (data.error === 'authorization_pending') return c.json({ status: 'pending' }); if (data.error === 'slow_down') return c.json({ status: 'slow_down', interval: data.interval }); @@ -369,8 +374,8 @@ export const copilotAuthPoll = async (c: CtxWithJson if (!data.access_token) return c.json({ status: 'error', error: 'Unknown response' }, 500); - const user = await fetchGitHubUser(data.access_token); - const accountType = await detectAccountType(data.access_token); + const user = await fetchGitHubUser(data.access_token, fetcher); + const accountType = await detectAccountType(data.access_token, fetcher); if (!isCopilotAccountType(accountType)) { return c.json({ status: 'error', error: 'Unsupported Copilot account type' }, 502); } @@ -401,7 +406,11 @@ export const copilotAuthPoll = async (c: CtxWithJson updatedAt: now, flagOverrides: {}, disabledPublicModelIds: [], - proxyFallbackList: [], + // Persist the override on initial create so subsequent data-plane + // calls honor the same chain. Existing rows keep their stored + // list — the override above already routes the poll itself + // correctly without clobbering a prior persisted choice. + proxyFallbackList: proxyFallbackList !== undefined ? normalizeProxyFallbackList(proxyFallbackList) : [], config, state: null, }; @@ -538,6 +547,9 @@ export const codexReimport = async (c: CtxWithJson) => return c.json(await serializeForResponse(next)); }; +// The body carries an optional `proxy_fallback_list` override so a refresh +// fired from an unsaved edit-form uses the in-progress chain rather than +// the persisted one. See proxy-resolution.ts for the layered policy. export const codexRefreshNow = async (c: CtxWithJson) => { const id = c.req.param('id'); if (!id) return c.json({ error: 'upstream id is required' }, 400); @@ -558,12 +570,15 @@ export const codexRefreshNow = async (c: CtxWithJson return c.json({ error: `Codex upstream is ${account.state}; re-import to recover` }, 400); } + const body = c.req.valid('json'); + let fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + try { - // Thread the per-upstream proxy-aware fetcher so the operator-pressed - // Refresh button respects the same fallback chain as the data-plane hot - // path. Without this, a Codex upstream behind a corporate proxy would - // dial direct here and silently fail under restricted egress. - const fetcher = (await createPerRequestFetcherForAdmin())(id); const tokens = await refreshCodexAccessToken(account.refresh_token, fetcher); const now = new Date(); const nextAccount = { diff --git a/packages/gateway/src/control-plane/upstreams/routes_test.ts b/packages/gateway/src/control-plane/upstreams/routes_test.ts index 53e0ea5e6..ea1c9b124 100644 --- a/packages/gateway/src/control-plane/upstreams/routes_test.ts +++ b/packages/gateway/src/control-plane/upstreams/routes_test.ts @@ -945,3 +945,108 @@ test('DELETE /api/upstreams sweeps orphaned proxy backoff rows', async () => { assertEquals(remaining.length, 1); assertEquals(remaining[0]!.upstreamId, 'other_upstream'); }); + +// --- pre-save proxy_fallback_list override --- + +test('POST /api/upstreams/:id/codex-refresh-now honors the proxy_fallback_list override over the persisted list', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + const created = (await (await requestApp('/api/upstreams/codex-import', authed(adminSession, codexAuthJsonImport()))).json()) as { id: string }; + + const resp = await requestApp( + `/api/upstreams/${created.id}/codex-refresh-now`, + authed(adminSession, { proxy_fallback_list: [{ id: 'p_unknown' }] }), + ); + assertEquals(resp.status, 400); + const body = (await resp.json()) as { error: string }; + assertEquals(body.error.toLowerCase().includes('unknown proxy id'), true); +}); + +test('POST /api/upstreams/copilot/auth/poll honors the proxy_fallback_list override', async () => { + const { adminSession } = await setupAppTest(); + const resp = await requestApp( + '/api/upstreams/copilot/auth/poll', + authed(adminSession, { device_code: 'dev', proxy_fallback_list: [{ id: 'p_unknown' }] }), + ); + // resolveControlPlaneFetcher throws synchronously when validating the + // override; the handler's outer catch maps that to a 502 with the + // error message intact. + assertEquals(resp.status, 502); + const body = (await resp.json()) as { error: string }; + assertEquals(body.error.toLowerCase().includes('unknown proxy id'), true); +}); + +test('POST /api/upstreams/copilot/auth/poll persists the override on the freshly-created row', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.proxies.insert({ id: 'p_real', name: 'Real', url: 'socks5://198.51.100.10:1080', dialTimeoutSeconds: null }); + + // Drive the device-flow poll deterministically: every GitHub-side call + // the handler makes (oauth token exchange, /user, /copilot_internal/user, + // and the post-save warmup's /copilot_internal/v2/token mint) must + // resolve, otherwise the warmup falls into copilot auth's withRetry + // backoff and the test stalls for ~7s before passing. + await withMockedFetch( + async (request: Request) => { + const url = new URL(request.url); + if (url.hostname === 'github.com' && url.pathname === '/login/oauth/access_token') { + return jsonResponse({ access_token: 'ghu_test', token_type: 'bearer', scope: 'read:user' }); + } + if (url.hostname === 'api.github.com' && url.pathname === '/user') { + return jsonResponse({ login: 'octo', avatar_url: 'https://example.com/a.png', name: 'Octo', id: 99 }); + } + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/user') { + return jsonResponse({ copilot_plan: 'individual' }); + } + if (url.hostname === 'api.github.com' && url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'ct_test', expires_at: Math.floor(Date.now() / 1000) + 1500, refresh_in: 1200, endpoints: { api: 'https://api.githubcopilot.com' } }); + } + // Models warmup probes the copilot api host; an empty list keeps the + // warmup quiet without exercising the catalog. + if (url.hostname === 'api.githubcopilot.com') { + return jsonResponse({ data: [] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => { + const resp = await requestApp( + '/api/upstreams/copilot/auth/poll', + authed(adminSession, { + device_code: 'dev', + // 'direct' short-circuits the override fetcher to direct so the + // mocks above serve the bootstrap; persistence still records the + // full chain. + proxy_fallback_list: [{ id: 'direct' }, { id: 'p_real' }], + }), + ); + assertEquals(resp.status, 200); + const body = (await resp.json()) as { status: string; upstream: { id: string; proxy_fallback_list: Array<{ id: string }> } }; + assertEquals(body.status, 'complete'); + assertEquals(body.upstream.proxy_fallback_list, [{ id: 'direct' }, { id: 'p_real' }]); + const stored = await repo.upstreams.getById(body.upstream.id); + assertEquals(stored?.proxyFallbackList, [{ id: 'direct' }, { id: 'p_real' }]); + }, + ); +}); + +test('POST /api/upstreams/:id/codex-refresh-now without an override falls back to the persisted list', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + + const created = (await (await requestApp('/api/upstreams/codex-import', authed(adminSession, codexAuthJsonImport()))).json()) as { id: string }; + + // No override → persisted ([]) → direct egress → the mocked fetch + // serves the refresh response. A 200 proves the "no override" path + // skipped catalog validation entirely. + await withMockedFetch( + () => jsonResponse({ access_token: 'at_rotated', refresh_token: 'rt_rotated', id_token: fakeIdToken({}), expires_in: 600 }), + async () => { + const resp = await requestApp( + `/api/upstreams/${created.id}/codex-refresh-now`, + authed(adminSession, {}), + ); + assertEquals(resp.status, 200); + }, + ); +});