Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Dockerfile.botenv
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,41 @@ 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
python3 python3-venv python3-pip python3-dev \
# 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 \
Comment on lines +42 to +49

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Installing Rust via rustup with --default-toolchain stable makes the image build non-reproducible (the toolchain can change over time), which can lead to unexpected build breakages. Consider pinning a specific Rust toolchain version via an ARG/ENV (and optionally verifying the installer) to keep builds deterministic.

Copilot uses AI. Check for mistakes.
&& chmod -R a+w $RUSTUP_HOME $CARGO_HOME

# Make openclaw CLI available on PATH
RUN ln -s /app/openclaw.mjs /usr/local/bin/openclaw

Expand Down
55 changes: 55 additions & 0 deletions src/discover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +227 to +246

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests stub globalThis.fetch and manually call vi.unstubAllGlobals() at the end of each test. If an assertion throws before cleanup, the stub can leak into subsequent tests and cause order-dependent failures. Prefer restoring the stub in an afterEach (or a try/finally) so globals are always reset.

Copilot uses AI. Check for mistakes.
});

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);
Expand Down
20 changes: 17 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,16 +293,20 @@ export async function buildServer(): Promise<FastifyInstance> {

// 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,
Comment on lines 294 to +298

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hsts: false disables Strict-Transport-Security unconditionally. If BotMaker is ever served over HTTPS (e.g., behind a reverse proxy that forwards headers), this removes an important security control. Consider making HSTS configurable (default enabled) and only disabling it when you know the external scheme is HTTP-only, rather than hard-coding it off for all deployments.

Suggested change
// 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,
// Determine whether to enable HSTS. Default is enabled; set BOTMAKER_HSTS=false
// in environments that are strictly HTTP-only (e.g., plain HTTP behind a LAN).
const enableHsts = process.env.BOTMAKER_HSTS !== 'false';
// Register security headers
await server.register(fastifyHelmet, {
// HSTS can cause issues when there is no TLS terminator; allow opting out
// via BOTMAKER_HSTS=false instead of disabling it unconditionally.
hsts: enableHsts,

Copilot uses AI. Check for mistakes.
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,
},
},
});
Expand Down Expand Up @@ -803,7 +807,17 @@ export async function buildServer(): Promise<FastifyInstance> {
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));
}
Comment on lines +813 to +820

Copilot AI Feb 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the isLocal branch, the handler bypasses isPrivateUrl(...) protocol validation and proceeds to build/fetch the URL. That means non-http(s) local URLs (e.g. ftp://localhost/...) would be treated as allowlisted and return 200 with empty models instead of a 400. Add an explicit protocol check (http/https only) before entering the local fast-path, or update isLocalDiscoveryUrl to also require an http(s) scheme.

Copilot uses AI. Check for mistakes.

const headers: Record<string, string> = { Host: originalHost };
if (request.body.apiKey) {
Expand Down
Loading