From 29cea02ea124da2a8dc429a67ed46c5e7864da19 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 17 Mar 2026 14:16:44 +0100 Subject: [PATCH 1/2] feat: add gateway proxy service for token management --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/menu.ts | 14 -- .../src/main/services/agent/service.test.ts | 9 +- apps/code/src/main/services/agent/service.ts | 155 +++------------ .../src/main/services/auth-proxy/service.ts | 177 ++++++++++++++++++ apps/code/src/main/trpc/routers/agent.ts | 4 - .../features/auth/stores/authStore.ts | 86 +-------- .../features/settings/hooks/useMcpServers.ts | 4 +- packages/agent/src/agent.ts | 10 +- packages/agent/src/gateway-models.ts | 33 ++-- packages/agent/src/types.ts | 1 + 12 files changed, 251 insertions(+), 245 deletions(-) create mode 100644 apps/code/src/main/services/auth-proxy/service.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 9c7c80601..e9916fea2 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -9,6 +9,7 @@ import { DatabaseService } from "../db/service"; import { AgentService } from "../services/agent/service"; import { AppLifecycleService } from "../services/app-lifecycle/service"; import { ArchiveService } from "../services/archive/service"; +import { AuthProxyService } from "../services/auth-proxy/service"; import { CloudTaskService } from "../services/cloud-task/service"; import { ConnectivityService } from "../services/connectivity/service"; import { ContextMenuService } from "../services/context-menu/service"; @@ -50,6 +51,7 @@ container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository); container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); container.bind(MAIN_TOKENS.AgentService).to(AgentService); +container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService); container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService); container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index bc694f092..2e2da40e9 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -18,6 +18,7 @@ export const MAIN_TOKENS = Object.freeze({ // Services AgentService: Symbol.for("Main.AgentService"), + AuthProxyService: Symbol.for("Main.AuthProxyService"), ArchiveService: Symbol.for("Main.ArchiveService"), SuspensionService: Symbol.for("Main.SuspensionService"), AppLifecycleService: Symbol.for("Main.AppLifecycleService"), diff --git a/apps/code/src/main/menu.ts b/apps/code/src/main/menu.ts index 5c9c264e1..ab85dc356 100644 --- a/apps/code/src/main/menu.ts +++ b/apps/code/src/main/menu.ts @@ -10,7 +10,6 @@ import { } from "electron"; import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; -import type { AgentService } from "./services/agent/service"; import type { UIService } from "./services/ui/service"; import type { UpdatesService } from "./services/updates/service"; import { isDevBuild } from "./utils/env"; @@ -130,19 +129,6 @@ function buildFileMenu(): MenuItemConstructorOptions { container.get(MAIN_TOKENS.UIService).invalidateToken(); }, }, - { - label: "Mark all agent sessions for recreation", - click: () => { - const count = container - .get(MAIN_TOKENS.AgentService) - .markAllSessionsForRecreation(); - dialog.showMessageBox({ - type: "info", - title: "Sessions Marked", - message: `Marked ${count} session(s) for recreation.\n\nThey will be recreated on the next prompt.`, - }); - }, - }, { type: "separator" }, { label: "Clear application storage", diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts index 545c78f33..8475a1e79 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/apps/code/src/main/services/agent/service.test.ts @@ -152,6 +152,13 @@ function createMockDependencies() { posthogPluginService: { getPluginPath: vi.fn(() => "/mock/plugin"), }, + authProxy: { + start: vi.fn().mockResolvedValue("http://127.0.0.1:9999"), + stop: vi.fn().mockResolvedValue(undefined), + updateToken: vi.fn(), + getProxyUrl: vi.fn(() => "http://127.0.0.1:9999"), + isRunning: vi.fn(() => false), + }, }; } @@ -182,6 +189,7 @@ describe("AgentService", () => { deps.sleepService as never, deps.fsService as never, deps.posthogPluginService as never, + deps.authProxy as never, ); }); @@ -304,7 +312,6 @@ describe("AgentService", () => { createdAt: Date.now(), lastActivityAt: Date.now(), config: {}, - needsRecreation: false, promptPending: false, ...overrides, }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 0d88e190b..672a03966 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -29,6 +29,7 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { AuthProxyService } from "../auth-proxy/service"; import type { FsService } from "../fs/service"; import type { PosthogPluginService } from "../posthog-plugin/service"; import type { ProcessTrackingService } from "../process-tracking/service"; @@ -58,21 +59,6 @@ function getMockNodeDir(): string { } /** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */ -function hidePromptBlocks(prompt: ContentBlock[]): ContentBlock[] { - return prompt.map((block) => { - const existing = ( - block as ContentBlock & { _meta?: { ui?: Record } } - )._meta; - return { - ...block, - _meta: { - ...existing, - ui: { ...existing?.ui, hidden: true }, - }, - }; - }); -} - type MessageCallback = (message: unknown) => void; class NdJsonTap { @@ -216,8 +202,6 @@ interface ManagedSession { lastActivityAt: number; config: SessionConfig; interruptReason?: InterruptReason; - needsRecreation: boolean; - recreationPromise?: Promise; promptPending: boolean; pendingContext?: string; configOptions?: SessionConfigOption[]; @@ -269,6 +253,7 @@ export class AgentService extends TypedEventEmitter { private sleepService: SleepService; private fsService: FsService; private posthogPluginService: PosthogPluginService; + private authProxy: AuthProxyService; constructor( @inject(MAIN_TOKENS.ProcessTrackingService) @@ -279,12 +264,15 @@ export class AgentService extends TypedEventEmitter { fsService: FsService, @inject(MAIN_TOKENS.PosthogPluginService) posthogPluginService: PosthogPluginService, + @inject(MAIN_TOKENS.AuthProxyService) + authProxy: AuthProxyService, ) { super(); this.processTracking = processTracking; this.sleepService = sleepService; this.fsService = fsService; this.posthogPluginService = posthogPluginService; + this.authProxy = authProxy; powerMonitor.on("resume", () => this.checkIdleDeadlines()); } @@ -292,32 +280,20 @@ export class AgentService extends TypedEventEmitter { public updateToken(newToken: string): void { this.currentToken = newToken; - // Mark all sessions for recreation - they'll be recreated before the next prompt. - // We don't recreate immediately because the subprocess may be mid-response or - // waiting on a permission prompt. Recreation happens at a safe point. - for (const session of this.sessions.values()) { - session.needsRecreation = true; + if (this.authProxy.isRunning()) { + this.authProxy.updateToken(newToken); } - log.info("Token updated, marked sessions for recreation", { - sessionCount: this.sessions.size, - }); - } + process.env.ANTHROPIC_API_KEY = newToken; + process.env.ANTHROPIC_AUTH_TOKEN = newToken; + process.env.OPENAI_API_KEY = newToken; + process.env.POSTHOG_API_KEY = newToken; + process.env.POSTHOG_AUTH_HEADER = `Bearer ${newToken}`; - /** - * Mark all sessions for recreation (developer tool for testing token refresh). - * Sessions will be recreated before their next prompt. - */ - public markAllSessionsForRecreation(): number { - let count = 0; - for (const session of this.sessions.values()) { - session.needsRecreation = true; - count++; - } - log.info("Marked all sessions for recreation (dev tool)", { - sessionCount: count, + log.info("Token updated (proxy + env vars)", { + sessionCount: this.sessions.size, + proxyRunning: this.authProxy.isRunning(), }); - return count; } /** @@ -656,9 +632,9 @@ export class AgentService extends TypedEventEmitter { const channel = `agent-event:${taskRunId}`; const mockNodeDir = this.setupMockNodeEnvironment(); - this.setupEnvironment(credentials, mockNodeDir); + const proxyUrl = await this.ensureAuthProxy(credentials); + this.setupEnvironment(credentials, mockNodeDir, proxyUrl); - // Preview sessions don't persist logs — no real task exists const isPreview = taskId === "__preview__"; const agent = new Agent({ @@ -677,6 +653,7 @@ export class AgentService extends TypedEventEmitter { try { const acpConnection = await agent.run(taskId, taskRunId, { adapter, + gatewayUrl: proxyUrl, codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined, processCallbacks: { onProcessSpawned: (info) => { @@ -838,7 +815,6 @@ export class AgentService extends TypedEventEmitter { createdAt: Date.now(), lastActivityAt: Date.now(), config, - needsRecreation: false, promptPending: false, configOptions, }; @@ -884,76 +860,15 @@ export class AgentService extends TypedEventEmitter { } } - private async recreateSession(taskRunId: string): Promise { - const existing = this.sessions.get(taskRunId); - if (!existing) { - throw new Error(`Session not found for recreation: ${taskRunId}`); - } - - log.info("Recreating session", { taskRunId }); - - // Preserve state that should survive recreation - const config = existing.config; - const pendingContext = existing.pendingContext; - const configOptions = existing.configOptions; - - await this.cleanupSession(taskRunId); - - const newSession = await this.getOrCreateSession(config, true); - if (!newSession) { - throw new Error(`Failed to recreate session: ${taskRunId}`); - } - - if (pendingContext) { - newSession.pendingContext = pendingContext; - } - - if (configOptions) { - await Promise.all( - configOptions.map((opt) => - this.setSessionConfigOption( - taskRunId, - opt.id, - opt.currentValue, - ).catch((err) => { - log.warn("Failed to restore config option during recreation", { - taskRunId, - configId: opt.id, - err, - }); - }), - ), - ); - } - - return newSession; - } - async prompt( sessionId: string, prompt: ContentBlock[], ): Promise { - let session = this.sessions.get(sessionId); + const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Session not found: ${sessionId}`); } - // Recreate session if marked (token was refreshed while session was active) - if (session.needsRecreation) { - if (!session.recreationPromise) { - log.info("Recreating session before prompt (token refreshed)", { - sessionId, - }); - session.recreationPromise = this.recreateSession(sessionId).finally( - () => { - const s = this.sessions.get(sessionId); - if (s) s.recreationPromise = undefined; - }, - ); - } - session = await session.recreationPromise; - } - // Prepend pending context if present let finalPrompt = prompt; if (session.pendingContext) { @@ -991,20 +906,6 @@ export class AgentService extends TypedEventEmitter { stopReason: result.stopReason, _meta: result._meta as PromptOutput["_meta"], }; - } catch (err) { - if (isAuthError(err)) { - log.warn("Auth error during prompt, recreating session", { sessionId }); - session = await this.recreateSession(sessionId); - const result = await session.clientSideConnection.prompt({ - sessionId: getAgentSessionId(session), - prompt: hidePromptBlocks(finalPrompt), - }); - return { - stopReason: result.stopReason, - _meta: result._meta as PromptOutput["_meta"], - }; - } - throw err; } finally { session.promptPending = false; session.lastActivityAt = Date.now(); @@ -1222,9 +1123,16 @@ For git operations while detached: log.info("All agent sessions cleaned up"); } + private async ensureAuthProxy(credentials: Credentials): Promise { + const token = this.getToken(credentials.apiKey); + const llmGatewayUrl = getLlmGatewayUrl(credentials.apiHost); + return this.authProxy.start(llmGatewayUrl, token); + } + private setupEnvironment( credentials: Credentials, mockNodeDir: string, + proxyUrl: string, ): void { const token = this.getToken(credentials.apiKey); const currentPath = process.env.PATH || ""; @@ -1235,15 +1143,14 @@ For git operations while detached: process.env.ANTHROPIC_API_KEY = token; process.env.ANTHROPIC_AUTH_TOKEN = token; - const llmGatewayUrl = getLlmGatewayUrl(credentials.apiHost); - process.env.ANTHROPIC_BASE_URL = llmGatewayUrl; + process.env.ANTHROPIC_BASE_URL = proxyUrl; - const openaiBaseUrl = llmGatewayUrl.endsWith("/v1") - ? llmGatewayUrl - : `${llmGatewayUrl}/v1`; + const openaiBaseUrl = proxyUrl.endsWith("/v1") + ? proxyUrl + : `${proxyUrl}/v1`; process.env.OPENAI_BASE_URL = openaiBaseUrl; process.env.OPENAI_API_KEY = token; - process.env.LLM_GATEWAY_URL = llmGatewayUrl; + process.env.LLM_GATEWAY_URL = proxyUrl; process.env.CLAUDE_CODE_EXECUTABLE = getClaudeCliPath(); diff --git a/apps/code/src/main/services/auth-proxy/service.ts b/apps/code/src/main/services/auth-proxy/service.ts new file mode 100644 index 000000000..b671854ac --- /dev/null +++ b/apps/code/src/main/services/auth-proxy/service.ts @@ -0,0 +1,177 @@ +import http from "node:http"; +import { injectable } from "inversify"; +import { logger } from "../../utils/logger"; + +const log = logger.scope("auth-proxy"); + +@injectable() +export class AuthProxyService { + private server: http.Server | null = null; + private currentToken: string | null = null; + private gatewayUrl: string | null = null; + private port: number | null = null; + + async start(gatewayUrl: string, initialToken: string): Promise { + if (this.server) { + this.currentToken = initialToken; + this.gatewayUrl = gatewayUrl; + return this.getProxyUrl(); + } + + this.gatewayUrl = gatewayUrl; + this.currentToken = initialToken; + + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + return new Promise((resolve, reject) => { + this.server?.listen(0, "127.0.0.1", () => { + const addr = this.server?.address(); + if (typeof addr === "object" && addr) { + this.port = addr.port; + log.info("Auth proxy started", { port: this.port }); + resolve(this.getProxyUrl()); + } else { + reject(new Error("Failed to get proxy address")); + } + }); + + this.server?.on("error", (err) => { + log.error("Auth proxy server error", err); + reject(err); + }); + }); + } + + updateToken(token: string): void { + this.currentToken = token; + } + + getProxyUrl(): string { + if (!this.port) { + throw new Error("Auth proxy not started"); + } + return `http://127.0.0.1:${this.port}`; + } + + isRunning(): boolean { + return this.server !== null && this.port !== null; + } + + async stop(): Promise { + if (!this.server) return; + + return new Promise((resolve) => { + this.server?.close(() => { + log.info("Auth proxy stopped"); + this.server = null; + this.port = null; + resolve(); + }); + }); + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ): void { + if (!this.gatewayUrl || !this.currentToken) { + res.writeHead(503); + res.end("Proxy not configured"); + return; + } + + const base = this.gatewayUrl.endsWith("/") + ? this.gatewayUrl + : `${this.gatewayUrl}/`; + const incoming = (req.url ?? "/").replace(/^\//, ""); + const targetUrl = new URL(incoming, base); + + log.debug("Proxying request", { + method: req.method, + incoming: req.url, + target: targetUrl.toString(), + }); + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (key === "host" || key === "connection") continue; + if (typeof value === "string") { + headers[key] = value; + } + } + headers.authorization = `Bearer ${this.currentToken}`; + + const fetchOptions: RequestInit = { + method: req.method ?? "GET", + headers, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + fetchOptions.body = Buffer.concat(chunks); + this.forwardRequest(targetUrl.toString(), fetchOptions, res); + }); + } else { + this.forwardRequest(targetUrl.toString(), fetchOptions, res); + } + } + + private async forwardRequest( + url: string, + options: RequestInit, + res: http.ServerResponse, + ): Promise { + try { + const response = await fetch(url, options); + + log.debug("Proxy response", { + url, + status: response.status, + }); + + const responseHeaders: Record = {}; + const stripHeaders = new Set([ + "transfer-encoding", + "content-encoding", + "content-length", + ]); + response.headers.forEach((value, key) => { + if (stripHeaders.has(key)) return; + responseHeaders[key] = value; + }); + + res.writeHead(response.status, responseHeaders); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + const pump = async (): Promise => { + const { done, value } = await reader.read(); + if (done) { + res.end(); + return; + } + const canContinue = res.write(value); + if (canContinue) { + return pump(); + } + res.once("drain", () => pump()); + }; + + await pump(); + } catch (err) { + log.error("Proxy forward error", { url, err }); + if (!res.headersSent) { + res.writeHead(502); + } + res.end("Proxy error"); + } + } +} diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 44da2bd00..75a4a7960 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -156,10 +156,6 @@ export const agentRouter = router({ } }), - markAllForRecreation: publicProcedure.mutation(() => - getService().markAllSessionsForRecreation(), - ), - resetAll: publicProcedure.mutation(async () => { log.info("Resetting all sessions (logout/project switch)"); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index d48755c31..6c7de067f 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -5,7 +5,6 @@ import { OAUTH_SCOPE_VERSION, OAUTH_SCOPES, TOKEN_REFRESH_BUFFER_MS, - TOKEN_REFRESH_FORCE_MS, } from "@shared/constants/oauth"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRegion } from "@shared/types/oauth"; @@ -112,75 +111,6 @@ interface AuthState { let refreshTimeoutId: number | null = null; -function isTokenExpiringSoon(tokenExpiry: number | null): boolean { - return ( - tokenExpiry != null && tokenExpiry - Date.now() <= TOKEN_REFRESH_FORCE_MS - ); -} - -async function attemptRefreshWithActivityCheck( - getState: () => AuthState, -): Promise { - try { - // If the token is about to expire, skip the activity check and refresh immediately - if (isTokenExpiringSoon(getState().tokenExpiry)) { - log.warn( - "Token expiring imminently, forcing refresh despite active sessions", - ); - await getState().refreshAccessToken(); - return; - } - - // Refresh if there are no active sessions - const hasActive = await trpcClient.agent.hasActiveSessions.query(); - if (!hasActive) { - await getState().refreshAccessToken(); - return; - } - - await new Promise((resolve, reject) => { - let settled = false; - const settle = (reason: string, fn: () => Promise) => { - if (settled) return; - settled = true; - subscription.unsubscribe(); - window.clearInterval(expiryCheckId); - log.info(`Settling activity wait: ${reason}`); - fn().then(resolve).catch(reject); - }; - - // Subscribe to the idle event - const subscription = trpcClient.agent.onSessionsIdle.subscribe( - undefined, - { - onData: () => - settle("sessions idle", () => getState().refreshAccessToken()), - onError: (error) => { - log.warn( - "Sessions idle subscription failed, refreshing anyway", - error, - ); - settle("subscription error", () => getState().refreshAccessToken()); - }, - }, - ); - - // Safety net: if the token is about to expire while we wait, force refresh - const expiryCheckId = window.setInterval(() => { - if (isTokenExpiringSoon(getState().tokenExpiry)) { - settle("token expiring imminently", () => - getState().refreshAccessToken(), - ); - } - }, TOKEN_REFRESH_FORCE_MS / 2); - }); - } catch (error) { - // IPC call failed — refresh anyway (better than letting the token expire) - log.warn("Activity check failed, refreshing token anyway", error); - await getState().refreshAccessToken(); - } -} - export const useAuthStore = create()( subscribeWithSelector( persist( @@ -540,14 +470,18 @@ export const useAuthStore = create()( if (timeUntilRefresh > 0) { refreshTimeoutId = window.setTimeout(() => { - attemptRefreshWithActivityCheck(get).catch((error) => { - log.error("Proactive token refresh failed:", error); - }); + get() + .refreshAccessToken() + .catch((error) => { + log.error("Proactive token refresh failed:", error); + }); }, timeUntilRefresh); } else { - attemptRefreshWithActivityCheck(get).catch((error) => { - log.error("Immediate token refresh failed:", error); - }); + get() + .refreshAccessToken() + .catch((error) => { + log.error("Immediate token refresh failed:", error); + }); } }, diff --git a/apps/code/src/renderer/features/settings/hooks/useMcpServers.ts b/apps/code/src/renderer/features/settings/hooks/useMcpServers.ts index 456566e54..1473728f2 100644 --- a/apps/code/src/renderer/features/settings/hooks/useMcpServers.ts +++ b/apps/code/src/renderer/features/settings/hooks/useMcpServers.ts @@ -59,9 +59,7 @@ export function useMcpServers() { const [installingUrl, setInstallingUrl] = useState(null); const queryClient = useQueryClient(); const markSessionsForMcpRefresh = useCallback(() => { - trpcClient.agent.markAllForRecreation.mutate().catch(() => { - // Non-blocking best effort: sessions will still refresh on next reconnect. - }); + // MCP config changes are picked up on next session creation. }, []); const { data: installations, isLoading: installationsLoading } = diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 438f3c83c..13e525ea2 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -5,7 +5,7 @@ import { import { BLOCKED_MODELS, DEFAULT_GATEWAY_MODEL, - fetchArrayModels, + fetchModelsList, } from "./gateway-models"; import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; @@ -45,7 +45,7 @@ export class Agent { } } - private _configureLlmGateway(_adapter?: "claude" | "codex"): { + private _configureLlmGateway(overrideUrl?: string): { gatewayUrl: string; apiKey: string; } | null { @@ -54,7 +54,7 @@ export class Agent { } try { - const gatewayUrl = this.posthogAPI.getLlmGatewayUrl(); + const gatewayUrl = overrideUrl ?? this.posthogAPI.getLlmGatewayUrl(); const apiKey = this.posthogAPI.getApiKey(); process.env.OPENAI_BASE_URL = `${gatewayUrl}/v1`; @@ -74,7 +74,7 @@ export class Agent { taskRunId: string, options: TaskExecutionOptions = {}, ): Promise { - const gatewayConfig = this._configureLlmGateway(options.adapter); + const gatewayConfig = this._configureLlmGateway(options.gatewayUrl); this.logger.info("Configured LLM gateway", { adapter: options.adapter, }); @@ -86,7 +86,7 @@ export class Agent { ? options.model : undefined; if (options.adapter === "codex" && gatewayConfig) { - const models = await fetchArrayModels({ + const models = await fetchModelsList({ gatewayUrl: gatewayConfig.gatewayUrl, }); const codexModelIds = models diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 47ec7091b..fb28aacc1 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -19,7 +19,7 @@ export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-6"; export const BLOCKED_MODELS = new Set(["gpt-5-mini", "openai/gpt-5-mini"]); -type ArrayModelsResponse = +type ModelsListResponse = | { data?: Array<{ id?: string; owned_by?: string }>; models?: Array<{ id?: string; owned_by?: string }>; @@ -79,53 +79,50 @@ export function isAnthropicModel(model: GatewayModel): boolean { return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); } -export interface ArrayModelInfo { +export interface ModelInfo { id: string; owned_by?: string; } -let arrayModelsCache: { - models: ArrayModelInfo[]; +let modelsListCache: { + models: ModelInfo[]; expiry: number; url: string; } | null = null; -export async function fetchArrayModels( +export async function fetchModelsList( options?: FetchGatewayModelsOptions, -): Promise { +): Promise { const gatewayUrl = options?.gatewayUrl ?? process.env.ANTHROPIC_BASE_URL; if (!gatewayUrl) { return []; } if ( - arrayModelsCache && - arrayModelsCache.url === gatewayUrl && - Date.now() < arrayModelsCache.expiry + modelsListCache && + modelsListCache.url === gatewayUrl && + Date.now() < modelsListCache.expiry ) { - return arrayModelsCache.models; + return modelsListCache.models; } try { - const base = new URL(gatewayUrl); - base.pathname = "/array/v1/models"; - base.search = ""; - base.hash = ""; - const response = await fetch(base.toString()); + const modelsUrl = `${gatewayUrl}/v1/models`; + const response = await fetch(modelsUrl); if (!response.ok) { return []; } - const data = (await response.json()) as ArrayModelsResponse; + const data = (await response.json()) as ModelsListResponse; const models = Array.isArray(data) ? data : (data.data ?? data.models ?? []); - const results: ArrayModelInfo[] = []; + const results: ModelInfo[] = []; for (const model of models) { const id = model?.id ? String(model.id) : ""; if (!id) continue; results.push({ id, owned_by: model?.owned_by }); } - arrayModelsCache = { + modelsListCache = { models: results, expiry: Date.now() + CACHE_TTL, url: gatewayUrl, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 7c6abd5b6..55cbc4ad9 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -109,6 +109,7 @@ export interface TaskExecutionOptions { repositoryPath?: string; adapter?: "claude" | "codex"; model?: string; + gatewayUrl?: string; codexBinaryPath?: string; processCallbacks?: ProcessSpawnedCallback; } From e658d2b011f7d3cc532b9cc7776fdfa1717032fc Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo <32547391+jonathanlab@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:47:36 +0100 Subject: [PATCH 2/2] Potential fix for code scanning alert no. 238: Server-side request forgery Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../src/main/services/auth-proxy/service.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/code/src/main/services/auth-proxy/service.ts b/apps/code/src/main/services/auth-proxy/service.ts index b671854ac..e40c06584 100644 --- a/apps/code/src/main/services/auth-proxy/service.ts +++ b/apps/code/src/main/services/auth-proxy/service.ts @@ -88,6 +88,36 @@ export class AuthProxyService { const incoming = (req.url ?? "/").replace(/^\//, ""); const targetUrl = new URL(incoming, base); + // Validate that the resolved URL stays within the configured gateway origin + const gatewayBase = new URL(base); + const normalizePort = (u: URL): string => { + if (u.port) return u.port; + if (u.protocol === "https:") return "443"; + if (u.protocol === "http:") return "80"; + return ""; + }; + + const targetPort = normalizePort(targetUrl); + const gatewayPort = normalizePort(gatewayBase); + + const sameOrigin = + targetUrl.protocol === gatewayBase.protocol && + targetUrl.hostname === gatewayBase.hostname && + targetPort === gatewayPort; + + const hasPathTraversal = targetUrl.pathname.includes(".."); + + if (!sameOrigin || hasPathTraversal) { + log.warn("Rejected proxy request with invalid target URL", { + method: req.method, + incoming: req.url, + target: targetUrl.toString(), + }); + res.writeHead(403); + res.end("Forbidden"); + return; + } + log.debug("Proxying request", { method: req.method, incoming: req.url,