Skip to content

Commit f4b3abe

Browse files
authored
Merge pull request #1552 from mnfst/docker-fixes
fix(docker): harden Docker release and routing correctness
2 parents 29bef17 + ad2adbf commit f4b3abe

22 files changed

+646
-58
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
Docker release hardening pass: parameterize POSTGRES_PASSWORD and wire `.env.example` through `install.sh`; bind port 3001 to `127.0.0.1` by default; drop stale `MANIFEST_TRUST_LAN` from docs; replace OpenClaw-specific meta tags in the SPA with agent-neutral copy. `/api/v1/routing/:agent/status` now returns a structured `{ enabled, reason }` shape and only claims `enabled: true` when at least one tier resolves to a real model (`reason: no_provider | no_routable_models | pricing_cache_empty`). Provider connect rejects unknown providers and normalises casing. Tier override rejects unknown models with a helpful list. New `GET /api/v1/routing/pricing-health` and `POST /api/v1/routing/pricing/refresh` endpoints plus a Routing-page banner when the OpenRouter pricing cache is empty. Workspace-card and per-agent message counts now exclude error and fallback-error rows.

docker/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ BETTER_AUTH_SECRET=
1313

1414
# ─── Database ──────────────────────────────────────────────────────────────
1515

16-
# Override the bundled Postgres password for hardening.
16+
# To harden the bundled Postgres password, set BOTH of the variables below.
17+
# They must agree. Special characters in the password must be percent-encoded
18+
# in DATABASE_URL (e.g. `@` → `%40`, `:` → `%3A`, `/` → `%2F`).
1719
# POSTGRES_PASSWORD=change-me
20+
# DATABASE_URL=postgresql://manifest:change-me@postgres:5432/manifest
1821

1922

2023
# ─── Email provider (optional) ─────────────────────────────────────────────

docker/DOCKER_README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ Works with 300+ models across OpenAI, Anthropic, Google Gemini, DeepSeek, xAI, M
6262

6363
Three paths, ordered from fastest to most hands-on. All three end in the same place: a running stack that walks you through the **setup wizard** at [http://localhost:3001](http://localhost:3001) to create your admin account.
6464

65+
> **Heads up on network binding.** The bundled compose file binds port 3001 to `127.0.0.1` only, so the dashboard is reachable on the host machine but not over the LAN. See [Custom port](#custom-port) to expose it beyond localhost.
66+
6567
### Option 1: Quickstart install script (recommended)
6668

67-
One command. The script downloads `docker/docker-compose.yml`, generates a fresh `BETTER_AUTH_SECRET` and injects it into the compose file, brings up the stack, and waits for the healthcheck to go green.
69+
One command. The script downloads `docker/docker-compose.yml` and `docker/.env.example`, writes a fresh `BETTER_AUTH_SECRET` into `.env`, brings up the stack, and waits for the healthcheck to go green.
6870

6971
```bash
7072
bash <(curl -sSL https://raw.githubusercontent.com/mnfst/manifest/main/docker/install.sh)
@@ -82,20 +84,24 @@ Flags: `--dir <path>` (install into a custom directory, defaults to `./manifest`
8284

8385
### Option 2: Docker Compose (manual)
8486

85-
Same underlying flow as the install script, but you drive it yourself so you can edit the compose file before booting the stack.
87+
Same underlying flow as the install script, but you drive it yourself so you can edit the config before booting the stack.
8688

87-
1. Download the compose file:
89+
1. Download the compose file and the env template into the same directory:
8890

8991
```bash
9092
curl -O https://raw.githubusercontent.com/mnfst/manifest/main/docker/docker-compose.yml
93+
curl -O https://raw.githubusercontent.com/mnfst/manifest/main/docker/.env.example
94+
cp .env.example .env
9195
```
9296

93-
2. Generate a secret and paste it over the `BETTER_AUTH_SECRET` placeholder in `docker-compose.yml`:
97+
2. Generate a secret and paste it into the `BETTER_AUTH_SECRET=` line in `.env`:
9498

9599
```bash
96100
openssl rand -hex 32
97101
```
98102

103+
(Optional: to use a stronger database password, set BOTH `POSTGRES_PASSWORD` and `DATABASE_URL` in `.env` — they must agree, and any special characters in the password need to be percent-encoded in the URL.)
104+
99105
3. Start it:
100106

101107
```bash
@@ -191,12 +197,24 @@ Or in docker-compose.yml:
191197

192198
```yaml
193199
ports:
194-
- "8080:3001"
195-
environment:
196-
- BETTER_AUTH_URL=http://localhost:8080
200+
- "127.0.0.1:8080:3001"
201+
```
202+
203+
…and in `.env`:
204+
205+
```env
206+
BETTER_AUTH_URL=http://localhost:8080
197207
```
198208

199-
If you see "Invalid origin" on the login page, `BETTER_AUTH_URL` doesn't match the port you're using.
209+
### Exposing on the LAN
210+
211+
By default the compose file binds port 3001 to `127.0.0.1` only — the dashboard is reachable from the host but not from other machines on the network. To expose it on the LAN:
212+
213+
1. Edit `docker-compose.yml` and change the `ports` line from `"127.0.0.1:3001:3001"` to `"3001:3001"`.
214+
2. In `.env`, set `BETTER_AUTH_URL` to the host you'll reach the dashboard on — e.g. `http://192.168.1.20:3001` or `https://manifest.mydomain.com`. This MUST match the URL in the browser or Better Auth will reject the login with "Invalid origin".
215+
3. `docker compose up -d` to apply.
216+
217+
If you see "Invalid origin" on the login page, `BETTER_AUTH_URL` doesn't match the URL you're accessing the dashboard on. The host matters as much as the port.
200218

201219
## Image tags
202220

@@ -256,7 +274,6 @@ docker compose down -v # ⚠ destroys all data
256274
| `PORT` | No | `3001` | Internal server port |
257275
| `NODE_ENV` | No | `production` | Set `development` for auto-migrations |
258276
| `SEED_DATA` | No | `false` | Seed demo data on startup |
259-
| `MANIFEST_TRUST_LAN` | No | `false` | Trust private network IPs (needed in Docker) |
260277

261278
Full env var reference: [github.com/mnfst/manifest](https://github.com/mnfst/manifest)
262279

docker/docker-compose.yml

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,34 @@
22
# http://localhost:3001 and the app walks you through creating the first
33
# admin account via the setup wizard at /setup. No hardcoded credentials.
44
#
5+
# Configuration comes from a `.env` file next to this compose file.
6+
# Copy `.env.example` to `.env` and set at least BETTER_AUTH_SECRET before
7+
# booting. The install script does this automatically.
8+
#
59
# Before exposing this instance beyond localhost:
6-
# - Replace BETTER_AUTH_SECRET below with a real secret. Generate one
7-
# with: openssl rand -hex 32
10+
# - Set a real BETTER_AUTH_SECRET in .env (openssl rand -hex 32).
11+
# - To harden the database password, set BOTH `POSTGRES_PASSWORD` AND
12+
# `DATABASE_URL` in .env. They must agree — the password in the URL
13+
# must match POSTGRES_PASSWORD, and any special characters must be
14+
# percent-encoded in the URL.
815
# - Confirm your admin password is strong (the setup wizard enforces
916
# at least 8 characters).
1017
# - If you enable email verification (set EMAIL_PROVIDER / EMAIL_API_KEY),
1118
# set BETTER_AUTH_URL to a reachable public URL so the verification
1219
# links resolve.
20+
# - The app port binds to 127.0.0.1 by default. To expose on the LAN,
21+
# change `127.0.0.1:3001:3001` below to `3001:3001` AND update
22+
# BETTER_AUTH_URL in .env to match the host you reach it on.
1323

1424
services:
1525
manifest:
1626
image: manifestdotbuild/manifest:latest
1727
ports:
18-
- "3001:3001"
28+
- "127.0.0.1:3001:3001"
1929
environment:
20-
- DATABASE_URL=postgresql://manifest:manifest@postgres:5432/manifest
21-
# ⚠ Placeholder. Replace before any non-localhost use (see top of file).
22-
- BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string!!
23-
- BETTER_AUTH_URL=http://localhost:3001
30+
- DATABASE_URL=${DATABASE_URL:-postgresql://manifest:manifest@postgres:5432/manifest}
31+
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set in .env}
32+
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3001}
2433
- SEED_DATA=false
2534
- NODE_ENV=production
2635
- AUTO_MIGRATE=true
@@ -64,7 +73,7 @@ services:
6473
image: postgres:16-alpine@sha256:20edbde7749f822887a1a022ad526fde0a47d6b2be9a8364433605cf65099416
6574
environment:
6675
- POSTGRES_USER=manifest
67-
- POSTGRES_PASSWORD=manifest
76+
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-manifest}
6877
- POSTGRES_DB=manifest
6978
volumes:
7079
- pgdata:/var/lib/postgresql/data

docker/install.sh

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
#!/usr/bin/env bash
22
# Manifest — self-host quick install
33
#
4-
# Downloads the Docker Compose file, generates a BETTER_AUTH_SECRET and
5-
# writes it into the compose file (replacing the placeholder), then brings
6-
# up the stack. Designed for first-time self-hosters who want a one-command
7-
# setup. After the stack is healthy, visit http://localhost:3001 — the
8-
# setup wizard walks you through creating the first admin account.
4+
# Downloads the Docker Compose file and the `.env.example` template,
5+
# generates a BETTER_AUTH_SECRET, writes it into a local `.env`, then
6+
# brings up the stack. Designed for first-time self-hosters who want a
7+
# one-command setup. After the stack is healthy, visit http://localhost:3001
8+
# — the setup wizard walks you through creating the first admin account.
99
#
1010
# Usage:
1111
# bash install.sh # install into ./manifest
@@ -97,6 +97,15 @@ else
9797
|| die "Failed to download docker-compose.yml"
9898
fi
9999

100+
log "Downloading .env.example"
101+
ENV_PATH="$INSTALL_DIR/.env"
102+
if [[ "$DRY_RUN" -eq 1 ]]; then
103+
printf ' \033[2m$ curl -sSLf %s/.env.example -o %s\033[0m\n' "$REPO_RAW" "$ENV_PATH"
104+
else
105+
curl -sSLf "$REPO_RAW/.env.example" -o "$ENV_PATH" \
106+
|| die "Failed to download .env.example"
107+
fi
108+
100109
log "Generating BETTER_AUTH_SECRET"
101110
if [[ "$DRY_RUN" -eq 1 ]]; then
102111
SECRET="<generated-at-install-time>"
@@ -108,18 +117,25 @@ else
108117
esac
109118
fi
110119

111-
log "Writing secret into docker-compose.yml"
112-
COMPOSE_PATH="$INSTALL_DIR/docker-compose.yml"
113-
PLACEHOLDER='BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string!!'
120+
log "Writing secret into .env"
114121
if [[ "$DRY_RUN" -eq 1 ]]; then
115-
printf ' \033[2m$ replace "%s" → BETTER_AUTH_SECRET=<generated> in %s\033[0m\n' "$PLACEHOLDER" "$COMPOSE_PATH"
122+
printf ' \033[2m$ replace "BETTER_AUTH_SECRET=" → "BETTER_AUTH_SECRET=<generated>" in %s\033[0m\n' "$ENV_PATH"
116123
else
117-
if ! grep -qF "$PLACEHOLDER" "$COMPOSE_PATH"; then
118-
die "Placeholder BETTER_AUTH_SECRET not found in $COMPOSE_PATH — refusing to proceed."
124+
if ! grep -qE '^BETTER_AUTH_SECRET=$' "$ENV_PATH"; then
125+
die "Expected empty BETTER_AUTH_SECRET= line not found in $ENV_PATH — refusing to proceed."
119126
fi
120-
# openssl rand -hex produces only [0-9a-f], so plain bash string replacement is safe.
121-
compose_content=$(<"$COMPOSE_PATH")
122-
printf '%s' "${compose_content//$PLACEHOLDER/BETTER_AUTH_SECRET=$SECRET}" > "$COMPOSE_PATH"
127+
# Line-based rewrite — no sed, no quoting edge cases. openssl rand -hex
128+
# produces only [0-9a-f], so interpolation into the line is safe.
129+
new_content=""
130+
while IFS= read -r line || [[ -n "$line" ]]; do
131+
if [[ "$line" == "BETTER_AUTH_SECRET=" ]]; then
132+
new_content+="BETTER_AUTH_SECRET=$SECRET"$'\n'
133+
else
134+
new_content+="$line"$'\n'
135+
fi
136+
done < "$ENV_PATH"
137+
printf '%s' "$new_content" > "$ENV_PATH"
138+
chmod 600 "$ENV_PATH"
123139
fi
124140

125141
log "Starting the stack"
@@ -141,8 +157,14 @@ for _ in $(seq 1 24); do
141157
log "Manifest is up."
142158
cat <<EOF
143159
144-
Open: http://localhost:3001
145-
Setup: the first visit walks you through creating your admin account.
160+
Open: http://localhost:3001
161+
Setup: the first visit walks you through creating your admin account.
162+
Config: $INSTALL_DIR/.env (BETTER_AUTH_SECRET, OAuth keys, email provider)
163+
164+
Note: Port 3001 is bound to 127.0.0.1 only. To expose on your LAN,
165+
edit $INSTALL_DIR/docker-compose.yml and change the ports line
166+
from "127.0.0.1:3001:3001" to "3001:3001", then update
167+
BETTER_AUTH_URL in .env to match the host you'll access it on.
146168
147169
Stop: (cd $INSTALL_DIR && docker compose down)
148170
Wipe: (cd $INSTALL_DIR && docker compose down -v)

packages/backend/src/analytics/services/agent-analytics.service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
33
import { Repository } from 'typeorm';
44
import { AgentMessage } from '../../entities/agent-message.entity';
5-
import {
6-
rangeToInterval,
7-
rangeToPreviousInterval,
8-
} from '../../common/utils/range.util';
5+
import { rangeToInterval, rangeToPreviousInterval } from '../../common/utils/range.util';
96
import { computeTrend } from './query-helpers';
107
import { computeCutoff } from '../../common/utils/sql-dialect';
118

@@ -50,7 +47,10 @@ export class AgentAnalyticsService {
5047
.select('COALESCE(SUM(at.input_tokens), 0)', 'input')
5148
.addSelect('COALESCE(SUM(at.output_tokens), 0)', 'output')
5249
.addSelect('COALESCE(SUM(at.cache_read_tokens), 0)', 'cache_read')
53-
.addSelect('COUNT(*)', 'messages')
50+
.addSelect(
51+
`COUNT(*) FILTER (WHERE at.status IS NULL OR at.status NOT IN ('error', 'fallback_error'))`,
52+
'messages',
53+
)
5454
.where('at.timestamp >= :cutoff', { cutoff })
5555
.andWhere('at.tenant_id = :tenantId', { tenantId: scope.tenantId })
5656
.andWhere('at.agent_id = :agentId', { agentId: scope.agentId })

packages/backend/src/analytics/services/timeseries-queries.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,10 @@ export class TimeseriesQueriesService {
202202
const statsQb = this.turnRepo
203203
.createQueryBuilder('at')
204204
.select('at.agent_name', 'agent_name')
205-
.addSelect('COUNT(*)', 'message_count')
205+
.addSelect(
206+
`COUNT(*) FILTER (WHERE at.status IS NULL OR at.status NOT IN ('error', 'fallback_error'))`,
207+
'message_count',
208+
)
206209
.addSelect('MAX(at.timestamp)', 'last_active')
207210
.addSelect(`COALESCE(SUM(${costExpr}), 0)`, 'total_cost')
208211
.addSelect('COALESCE(SUM(at.input_tokens + at.output_tokens), 0)', 'total_tokens')

packages/backend/src/routing/dto/routing.dto.spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import 'reflect-metadata';
22
import { validate } from 'class-validator';
33
import { plainToInstance } from 'class-transformer';
4-
import { AgentNameParamDto, CopilotPollDto, SetFallbacksDto } from './routing.dto';
4+
import {
5+
AgentNameParamDto,
6+
ConnectProviderDto,
7+
CopilotPollDto,
8+
SetFallbacksDto,
9+
} from './routing.dto';
510

611
function toDto(data: Record<string, unknown>): AgentNameParamDto {
712
return plainToInstance(AgentNameParamDto, data);
@@ -71,6 +76,61 @@ describe('AgentNameParamDto', () => {
7176
});
7277
});
7378

79+
describe('ConnectProviderDto', () => {
80+
function toConnectDto(data: Record<string, unknown>): ConnectProviderDto {
81+
return plainToInstance(ConnectProviderDto, data);
82+
}
83+
84+
it('accepts a known provider id', async () => {
85+
const dto = toConnectDto({ provider: 'openai', apiKey: 'sk-abcdef' });
86+
const errors = await validate(dto);
87+
expect(errors).toHaveLength(0);
88+
expect(dto.provider).toBe('openai');
89+
});
90+
91+
it('normalizes provider casing to lowercase', async () => {
92+
const dto = toConnectDto({ provider: 'OpenAI' });
93+
const errors = await validate(dto);
94+
expect(errors).toHaveLength(0);
95+
expect(dto.provider).toBe('openai');
96+
});
97+
98+
it('trims whitespace around the provider name', async () => {
99+
const dto = toConnectDto({ provider: ' anthropic ' });
100+
const errors = await validate(dto);
101+
expect(errors).toHaveLength(0);
102+
expect(dto.provider).toBe('anthropic');
103+
});
104+
105+
it('accepts registered aliases (google -> gemini entry)', async () => {
106+
const dto = toConnectDto({ provider: 'google' });
107+
const errors = await validate(dto);
108+
expect(errors).toHaveLength(0);
109+
});
110+
111+
it('rejects an unknown provider name', async () => {
112+
const dto = toConnectDto({ provider: 'made-up-xyz' });
113+
const errors = await validate(dto);
114+
expect(errors.length).toBeGreaterThan(0);
115+
const flat = errors.flatMap((e) => Object.values(e.constraints ?? {}));
116+
expect(flat.join('\n')).toMatch(/provider must be one of/);
117+
});
118+
119+
it('rejects an empty provider', async () => {
120+
const dto = toConnectDto({ provider: '' });
121+
const errors = await validate(dto);
122+
expect(errors.length).toBeGreaterThan(0);
123+
});
124+
125+
it('passes through a non-string provider value without transforming', async () => {
126+
const dto = toConnectDto({ provider: 42 });
127+
// Transform preserves non-strings so class-validator can reject via @IsString
128+
expect(dto.provider).toBe(42 as unknown as string);
129+
const errors = await validate(dto);
130+
expect(errors.length).toBeGreaterThan(0);
131+
});
132+
});
133+
74134
describe('CopilotPollDto', () => {
75135
function toPollDto(data: Record<string, unknown>): CopilotPollDto {
76136
return plainToInstance(CopilotPollDto, data);

packages/backend/src/routing/dto/routing.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
ArrayMaxSize,
88
Matches,
99
} from 'class-validator';
10+
import { Transform } from 'class-transformer';
1011

1112
import { TIERS, AUTH_TYPES } from 'manifest-shared';
13+
import { PROVIDER_BY_ID_OR_ALIAS } from '../../common/constants/providers';
14+
15+
const KNOWN_PROVIDER_IDS: readonly string[] = Array.from(PROVIDER_BY_ID_OR_ALIAS.keys());
1216

1317
export class AgentNameParamDto {
1418
@IsString()
@@ -31,6 +35,10 @@ export class ProviderParamDto {
3135
export class ConnectProviderDto {
3236
@IsString()
3337
@IsNotEmpty()
38+
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : value))
39+
@IsIn(KNOWN_PROVIDER_IDS, {
40+
message: `provider must be one of: ${KNOWN_PROVIDER_IDS.join(', ')}`,
41+
})
3442
provider!: string;
3543

3644
@IsOptional()

0 commit comments

Comments
 (0)