From 7c3e8d813a8ed8e8816e5d24437f1c1578cc70e5 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Fri, 15 May 2026 21:58:06 +0200 Subject: [PATCH] fix(cloud-agent-next): translate local backend URLs for sandboxes --- dev/local/env-sync/parse.ts | 15 ++++- dev/local/env-sync/plan.test.ts | 35 +++++++++++ services/cloud-agent-next/.dev.vars.example | 12 ++-- .../src/session-service.test.ts | 62 +++++++++++++++++++ .../cloud-agent-next/src/session-service.ts | 20 +++++- 5 files changed, 132 insertions(+), 12 deletions(-) diff --git a/dev/local/env-sync/parse.ts b/dev/local/env-sync/parse.ts index ec8b67738c..14d79e83df 100644 --- a/dev/local/env-sync/parse.ts +++ b/dev/local/env-sync/parse.ts @@ -202,6 +202,8 @@ function toPkcs8IfNeeded(pem: string): string { type ResolvedValueSource = 'env-local' | 'generated' | 'exec' | 'default' | 'missing'; +const WORKER_LOCALHOST_URL_KEYS = new Set(['KILOCODE_BACKEND_BASE_URL', 'KILO_OPENROUTER_BASE']); + function resolveAnnotatedValue( key: string, entry: ExampleEntry, @@ -224,14 +226,21 @@ function resolveAnnotatedValue( const isHostname = key.includes('HOSTNAME') && !key.includes('URL'); const isWs = key.includes('_WS_'); const defaultUsesDockerHost = entry.defaultValue.includes('host.docker.internal'); + const defaultUsesWorkerLocalhost = + WORKER_LOCALHOST_URL_KEYS.has(key) && + (entry.defaultValue.includes('localhost') || entry.defaultValue.includes('127.0.0.1')); // LAN IP for container services, but never for ORIGINS keys. // Preserve host.docker.internal when the example default uses it // (sandbox containers need it to reach the host from inside Docker). + // Preserve localhost for worker-side URLs that are translated separately + // before being sent into sandbox containers. const host = defaultUsesDockerHost ? 'host.docker.internal' - : serviceUsesLanIp && !isOrigins && lanIp - ? lanIp - : 'localhost'; + : defaultUsesWorkerLocalhost + ? 'localhost' + : serviceUsesLanIp && !isOrigins && lanIp + ? lanIp + : 'localhost'; const protocol = isWs ? 'ws' : 'http'; const resolvedParts: string[] = []; diff --git a/dev/local/env-sync/plan.test.ts b/dev/local/env-sync/plan.test.ts index 8bfc39f0b0..0a5ddcecae 100644 --- a/dev/local/env-sync/plan.test.ts +++ b/dev/local/env-sync/plan.test.ts @@ -384,3 +384,38 @@ test('preserves host.docker.internal in @url defaults for useLanIp services', () repo.cleanup(); } }); + +test('preserves localhost in worker-side @url defaults for useLanIp services', () => { + const repo = createRepo({ + '.env.local': '', + [`${workerDir}/package.json`]: JSON.stringify( + { scripts: { dev: "wrangler dev --env 'dev'" } }, + null, + 2 + ), + [`${workerDir}/wrangler.jsonc`]: '{ "dev": { "port": 8794 } }', + [`${workerDir}/.dev.vars.example`]: [ + '# @url nextjs', + 'KILOCODE_BACKEND_BASE_URL=http://localhost:3000', + '# @url nextjs/api', + 'KILO_OPENROUTER_BASE=http://localhost:3000/api', + '# @url cloud-agent-next', + 'WORKER_URL=http://host.docker.internal:8794', + '', + ].join('\n'), + }); + try { + const plan = computePlan(repo.root, new Set(['cloud-agent-next'])); + assert.equal(plan.missingEnvLocal, false); + assert.equal(plan.devVarsChanges.length, 1); + const [change] = plan.devVarsChanges; + assert.ok(change); + assert.equal(change.isNew, true); + const content = change.newFileContent ?? ''; + assert.ok(content.includes('KILOCODE_BACKEND_BASE_URL=http://localhost:3000')); + assert.ok(content.includes('KILO_OPENROUTER_BASE=http://localhost:3000/api')); + assert.ok(content.includes('WORKER_URL=http://host.docker.internal:8794')); + } finally { + repo.cleanup(); + } +}); diff --git a/services/cloud-agent-next/.dev.vars.example b/services/cloud-agent-next/.dev.vars.example index e4f2ded396..9f698ac838 100644 --- a/services/cloud-agent-next/.dev.vars.example +++ b/services/cloud-agent-next/.dev.vars.example @@ -17,14 +17,14 @@ INTERNAL_API_SECRET=your-internal-api-secret-here #KILOCODE_TOKEN_OVERRIDE=your-override-token-here #KILOCODE_ORG_ID_OVERRIDE=your-override-org-id-here -# Kilocode backend base URL and KILO_OPENROUTER_BASE for API calls and session environment variables -# For local development, point to your local kilocode-backend. -# Sandbox containers run under Docker Desktop, so `host.docker.internal` -# resolves to the host where Next.js is listening. +# Kilocode backend URLs used by the worker for local server-side fetches and +# CLI provider config. Use a hostname reachable from the worker process. Before +# these values are sent into sandbox containers, the worker translates +# localhost/127.0.0.1 to host.docker.internal automatically. # @url nextjs -KILOCODE_BACKEND_BASE_URL=http://host.docker.internal:3000 +KILOCODE_BACKEND_BASE_URL=http://localhost:3000 # @url nextjs/api -KILO_OPENROUTER_BASE=http://host.docker.internal:3000/api +KILO_OPENROUTER_BASE=http://localhost:3000/api # Worker base URL used by the wrapper to connect to /ingest. # Sandbox containers reach the host via `host.docker.internal`. diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index f605bd0a2e..64b44823b0 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -67,6 +67,7 @@ import { cleanupWorkspace as mockCleanupWorkspace, } from './workspace.js'; import { + backendUrlForSandbox, buildAgentEntryFromRuntimeAgent, InvalidSessionMetadataError, SessionService, @@ -1353,6 +1354,55 @@ describe('SessionService', () => { }); }); + it('translates worker-local backend URLs before injecting sandbox environment', async () => { + const fakeSession = { + exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + gitCheckout: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + }; + const sandboxCreateSession = vi.fn().mockResolvedValue(fakeSession); + const sandbox = { + createSession: sandboxCreateSession, + mkdir: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue({ exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + } as unknown as SandboxInstance; + const sessionId: SessionId = 'agent_local_backend_url'; + mockedSetupWorkspace.mockResolvedValue({ + workspacePath: `/workspace/org/user/sessions/${sessionId}`, + sessionHome: `/home/${sessionId}`, + }); + + const service = new SessionService(); + await service.initiate({ + sandbox, + sandboxId: 'org__user', + orgId: 'org', + userId: 'user', + sessionId, + kilocodeToken: 'token', + kilocodeModel: 'test-model', + githubRepo: 'acme/repo', + env: { + ...mockEnv, + KILOCODE_BACKEND_BASE_URL: 'http://localhost:3000', + KILO_OPENROUTER_BASE: 'http://localhost:3000/api', + }, + }); + + const callArgs = sandboxCreateSession.mock.calls[0]?.[0] as { env: Record }; + expect(callArgs.env.KILOCODE_BACKEND_BASE_URL).toBe('http://host.docker.internal:3000'); + expect(callArgs.env.KILO_API_URL).toBe('http://host.docker.internal:3000'); + + const configContent = JSON.parse(callArgs.env.KILO_CONFIG_CONTENT) as { + provider: { kilo: { options: { baseURL?: string } } }; + }; + expect(configContent.provider.kilo.options.baseURL).toBe( + 'http://host.docker.internal:3000/api' + ); + }); + it('should handle special characters in env var values', async () => { const fakeSession = { exec: vi.fn().mockResolvedValue({ success: true, exitCode: 0 }), @@ -3981,3 +4031,15 @@ describe('buildAgentEntryFromRuntimeAgent', () => { expect(result.mode).toBe('subagent'); }); }); + +describe('backendUrlForSandbox', () => { + it.each([ + ['http://localhost:3000', 'http://host.docker.internal:3000'], + ['http://127.0.0.1:3000', 'http://host.docker.internal:3000'], + ['http://localhost:3000/api', 'http://host.docker.internal:3000/api'], + ['https://api.kilo.ai', 'https://api.kilo.ai'], + ['not-a-url', 'not-a-url'], + ])('maps %s to %s', (input, expected) => { + expect(backendUrlForSandbox(input)).toBe(expected); + }); +}); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index f98214eec1..8eca55dde4 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -131,6 +131,19 @@ export function determineBranchName(sessionId: string, upstreamBranch?: string): return upstreamBranch ?? `session/${sessionId}`; } +export function backendUrlForSandbox(workerBackendUrl: string): string { + try { + const url = new URL(workerBackendUrl); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + url.hostname = 'host.docker.internal'; + return url.toString().replace(/\/$/, ''); + } + } catch { + // Non-URL value: leave untouched. + } + return workerBackendUrl; +} + type SandboxRetryConfig = { maxAttempts: number; baseBackoffMs: number; @@ -798,7 +811,7 @@ export class SessionService { providerOptions.kilocodeOrganizationId = kilocodeOrganizationId; } if (env.KILO_OPENROUTER_BASE) { - providerOptions.baseURL = env.KILO_OPENROUTER_BASE; + providerOptions.baseURL = backendUrlForSandbox(env.KILO_OPENROUTER_BASE); } const isInteractive = createdOnPlatform == 'cloud-agent-web'; const commandGuardPolicy = getCommandGuardPolicy(createdOnPlatform); @@ -963,9 +976,10 @@ export class SessionService { } if (env.KILOCODE_BACKEND_BASE_URL) { - envVars.KILOCODE_BACKEND_BASE_URL = env.KILOCODE_BACKEND_BASE_URL; + const sandboxUrl = backendUrlForSandbox(env.KILOCODE_BACKEND_BASE_URL); + envVars.KILOCODE_BACKEND_BASE_URL = sandboxUrl; // Used by kilo server to check user auth to send to ingest - envVars.KILO_API_URL = env.KILOCODE_BACKEND_BASE_URL; + envVars.KILO_API_URL = sandboxUrl; } if (env.KILO_SESSION_INGEST_URL) {