diff --git a/Dockerfile.botenv b/Dockerfile.botenv index b9ae3d2..9b85708 100644 --- a/Dockerfile.botenv +++ b/Dockerfile.botenv @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ git ca-certificates curl \ pkg-config cmake ninja-build \ + autoconf automake libtool meson \ # Debugging gdb strace \ # Python @@ -21,14 +22,33 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Dev libraries libssl-dev libffi-dev \ zlib1g-dev libbz2-dev liblzma-dev libreadline-dev libsqlite3-dev \ + libxml2-dev libxslt-dev \ + libcurl4-openssl-dev \ + libncurses-dev \ + libpcre2-dev \ + libprotobuf-dev protobuf-compiler \ # CLI utilities jq ripgrep fd-find less \ - unzip zip xz-utils \ + unzip zip xz-utils zstd \ + wget tree file patch \ + openssh-client rsync \ + # HDL/FPGA tools + yosys ghdl \ # Process/network tools procps lsof \ iproute2 netcat-openbsd dnsutils \ && rm -rf /var/lib/apt/lists/* +# Install Rust toolchain via rustup (APT packages are severely outdated) +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable \ + && rustc --version \ + && chmod -R a+w $RUSTUP_HOME $CARGO_HOME + # Make openclaw CLI available on PATH RUN ln -s /app/openclaw.mjs /usr/local/bin/openclaw diff --git a/src/discover.test.ts b/src/discover.test.ts index 1f3e620..8407708 100644 --- a/src/discover.test.ts +++ b/src/discover.test.ts @@ -218,6 +218,61 @@ describe('/api/models/discover', () => { await server.close(); }); + it('should return models for local Ollama URL', async () => { + const fakeModels = { data: [{ id: 'qwen3-coder:30b' }, { id: 'nomic-embed-text' }] }; + const jsonBytes = new TextEncoder().encode(JSON.stringify(fakeModels)); + const mockFetch = vi.fn().mockResolvedValue( + new Response(jsonBytes, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + vi.stubGlobal('fetch', mockFetch); + + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://localhost:11434/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as { models: string[] }; + expect(body.models).toContain('qwen3-coder:30b'); + expect(body.models).toContain('nomic-embed-text'); + expect(mockFetch).toHaveBeenCalledOnce(); + // localhost is rewritten to host.docker.internal by toDockerHostUrl + expect(mockFetch.mock.calls[0][0]).toBe('http://host.docker.internal:11434/v1/models'); + + vi.unstubAllGlobals(); + await server.close(); + }); + + it('should return models for host.docker.internal URL', async () => { + const fakeModels = { data: [{ id: 'llama3:8b' }] }; + const jsonBytes = new TextEncoder().encode(JSON.stringify(fakeModels)); + const mockFetch = vi.fn().mockResolvedValue( + new Response(jsonBytes, { status: 200, headers: { 'content-type': 'application/json' } }), + ); + vi.stubGlobal('fetch', mockFetch); + + const server = await createTestServer(); + const token = await getAuthToken(server); + const response = await server.inject({ + method: 'POST', + url: '/api/models/discover', + payload: { baseUrl: 'http://host.docker.internal:11434/v1' }, + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body) as { models: string[] }; + expect(body.models).toContain('llama3:8b'); + expect(mockFetch).toHaveBeenCalledOnce(); + // URL should NOT be rewritten by resolveAndValidateUrl — passed through directly + expect(mockFetch.mock.calls[0][0]).toBe('http://host.docker.internal:11434/v1/models'); + + vi.unstubAllGlobals(); + await server.close(); + }); + it('should reject invalid URL', async () => { const server = await createTestServer(); const token = await getAuthToken(server); diff --git a/src/server.ts b/src/server.ts index 58643fc..ab57b53 100644 --- a/src/server.ts +++ b/src/server.ts @@ -293,16 +293,20 @@ export async function buildServer(): Promise { // Register security headers await server.register(fastifyHelmet, { + // BotMaker runs on plain HTTP behind a LAN; HSTS and upgrade-insecure-requests + // cause browsers to silently fail when there is no TLS terminator. + hsts: false, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], imgSrc: ["'self'", "data:"], connectSrc: ["'self'"], - fontSrc: ["'self'"], + fontSrc: ["'self'", "https://fonts.gstatic.com"], objectSrc: ["'none'"], frameAncestors: ["'none'"], + upgradeInsecureRequests: null, }, }, }); @@ -803,7 +807,17 @@ export async function buildServer(): Promise { const timeout = setTimeout(() => { controller.abort(); }, 5000); try { - const { resolvedUrl, originalHost } = await resolveAndValidateUrl(url); + let resolvedUrl: string; + let originalHost: string; + + if (isLocal) { + // Local URLs are explicitly allowlisted — skip DNS resolution. + // host.docker.internal is in /etc/hosts only; dns.resolve4() can't find it. + resolvedUrl = url; + originalHost = new URL(url).hostname; + } else { + ({ resolvedUrl, originalHost } = await resolveAndValidateUrl(url)); + } const headers: Record = { Host: originalHost }; if (request.body.apiKey) {