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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ CHATMOCK_IMAGE=storagetime/chatmock:latest
# Auth dir
CHATGPT_LOCAL_HOME=/data

# Prompt runtime defaults for the main service
CHATMOCK_PROMPT_DIR=/app/prompts/bare
CHATMOCK_PROMPT_CONFIG=/data/prompt-config-chatmock.json

# Prompt runtime defaults for the ClawMem service
CHATMOCK_CLAWMEM_PROMPT_DIR=/app/prompts/clawmem
CHATMOCK_CLAWMEM_PROMPT_CONFIG=/data/prompt-config-chatmock-clawmem.json

# Optional admin token for /admin/prompts endpoints
# CHATMOCK_ADMIN_TOKEN=change-me
# Optional comma-separated trusted admin IPs/CIDRs
# CHATMOCK_ADMIN_TRUSTED_IPS=172.17.0.1,10.0.0.0/8
# Allow non-local admin access only when paired with CHATMOCK_ADMIN_TOKEN
# CHATMOCK_ALLOW_ADMIN_EXTERNAL=false

# Isolated compose test stack defaults
CHATMOCK_TEST_PORT=18000
CHATMOCK_TEST_CLAWMEM_PORT=18001
CHATMOCK_TEST_LOGIN_PORT=11455
CHATMOCK_TEST_AUTH_VOLUME=chatmock_chatmock_data
CHATMOCK_TEST_PROMPT_DIR=/app/prompts/bare
CHATMOCK_TEST_PROMPT_CONFIG=/data/prompt-config-chatmock-test.json
CHATMOCK_TEST_CLAWMEM_PROMPT_DIR=/app/prompts/clawmem
CHATMOCK_TEST_CLAWMEM_PROMPT_CONFIG=/data/prompt-config-chatmock-clawmem-test.json

# show request/stream logs
VERBOSE=false

Expand Down
109 changes: 109 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,100 @@

4) Free to use it in whichever chat app you like!

## Live prompt switching

The compose stack now mounts the whole `./prompts` tree into `/app/prompts` and each service keeps its own runtime prompt config file under `/data`.

Default runtime prompt selection:

- `chatmock` uses `${CHATMOCK_PROMPT_DIR:-/app/prompts/bare}`
- `chatmock-clawmem` uses `${CHATMOCK_CLAWMEM_PROMPT_DIR:-/app/prompts/clawmem}`

Live prompt controls are local-only:

- `GET /admin/prompts`
- `POST /admin/prompts/reload`
- `POST /admin/prompts/config`

If `CHATMOCK_ADMIN_TOKEN` is configured, include the header:

```bash
-H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN"
```

By default the admin endpoints only accept:

- loopback clients
- known Docker host gateway addresses such as `172.17.0.1` and `172.18.0.1`

If your Docker/network setup uses a different host address, set:

```bash
CHATMOCK_ALLOW_ADMIN_EXTERNAL=true
```

and rely on `CHATMOCK_ADMIN_TOKEN` for access control. External admin access is rejected unless a non-empty `CHATMOCK_ADMIN_TOKEN` is configured.

For non-standard container networks, you can also trust specific admin IPs or CIDR ranges:

```bash
CHATMOCK_ADMIN_TRUSTED_IPS=172.17.0.1,10.0.0.0/8
```

Example: switch the main service to the ClawMem prompt directory without restarting the container:

```bash
curl -sS -X POST http://127.0.0.1:8000/admin/prompts/config \
-H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"prompt_dir":"/app/prompts/clawmem"}' | jq .
```

Example: reload prompt file contents after editing files in the mounted directory:

```bash
curl -sS -X POST http://127.0.0.1:8000/admin/prompts/reload \
-H "X-ChatMock-Admin-Token: $CHATMOCK_ADMIN_TOKEN" | jq .
```

## Isolated test stack

Use the override file to bring up a disposable stack on separate ports without touching the live services or their `/data` volume.

Bring it up:

```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml -p chatmock-test up -d chatmock chatmock-clawmem
```

Verify it:

```bash
curl -fsS http://127.0.0.1:18000/health
curl -fsS http://127.0.0.1:18001/health
```

Tear it down completely:

```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml -p chatmock-test down -v
```

The override isolates:

- ports: `18000`, `18001`, `11455`
- container names: `chatmock-test`, `chatmock-clawmem-test`
- prompt runtime config files under the test stack's own `/data`
- writable runtime state in the test stack's own `chatmock-test_chatmock_data` volume

The override also mounts the live stack's auth volume read-only at `/live-auth` and sets `CODEX_HOME=/live-auth`, so the test services can reuse existing auth without sharing the live stack's writable runtime state.

If your live stack uses a different Compose project name, set the external auth volume explicitly:

```bash
CHATMOCK_TEST_AUTH_VOLUME=<your_live_project>_chatmock_data
```

## Configuration
Set options in `.env` or pass environment variables:
- `PORT`: Container listening port (default 8000)
Expand All @@ -28,6 +122,21 @@ Set options in `.env` or pass environment variables:
- `CHATGPT_LOCAL_CLIENT_ID`: OAuth client id override (rarely needed)
- `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS`: `true|false` to add reasoning model variants to `/v1/models`
- `CHATGPT_LOCAL_ENABLE_WEB_SEARCH`: `true|false` to enable default web search tool
- `CHATMOCK_PROMPT_DIR`: default prompt directory for the main service
- `CHATMOCK_PROMPT_CONFIG`: runtime prompt config file for the main service
- `CHATMOCK_CLAWMEM_PROMPT_DIR`: default prompt directory for the ClawMem service
- `CHATMOCK_CLAWMEM_PROMPT_CONFIG`: runtime prompt config file for the ClawMem service
- `CHATMOCK_ADMIN_TOKEN`: optional token required by the `/admin/prompts/*` endpoints when set
- `CHATMOCK_ADMIN_TRUSTED_IPS`: optional comma-separated trusted admin IPs/CIDRs for non-standard local container networks
- `CHATMOCK_ALLOW_ADMIN_EXTERNAL`: allow non-local admin access only when paired with `CHATMOCK_ADMIN_TOKEN`
- `CHATMOCK_TEST_PORT`: main service port for the isolated test stack
- `CHATMOCK_TEST_CLAWMEM_PORT`: ClawMem service port for the isolated test stack
- `CHATMOCK_TEST_LOGIN_PORT`: login callback port for the isolated test stack
- `CHATMOCK_TEST_AUTH_VOLUME`: external live auth volume name mounted read-only into the isolated test stack
- `CHATMOCK_TEST_PROMPT_DIR`: main service prompt dir in the isolated test stack
- `CHATMOCK_TEST_PROMPT_CONFIG`: main service runtime prompt config in the isolated test stack
- `CHATMOCK_TEST_CLAWMEM_PROMPT_DIR`: ClawMem prompt dir in the isolated test stack
- `CHATMOCK_TEST_CLAWMEM_PROMPT_CONFIG`: ClawMem runtime prompt config in the isolated test stack

## Logs
Set `VERBOSE=true` to include extra logging for troubleshooting upstream or chat app requests. Please include and use these logs when submitting bug reports.
Expand Down
106 changes: 102 additions & 4 deletions chatmock/app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from __future__ import annotations

from flask import Flask, jsonify
import hmac
import ipaddress
import os

from flask import Flask, jsonify, request
from flask_sock import Sock
from werkzeug.exceptions import BadRequest, UnsupportedMediaType

from .config import BASE_INSTRUCTIONS, GPT5_CODEX_INSTRUCTIONS
from .config import get_prompt_manager
from .http import build_cors_headers
from .routes_openai import openai_bp
from .routes_ollama import ollama_bp
Expand All @@ -21,8 +26,16 @@ def create_app(
expose_reasoning_models: bool = False,
default_web_search: bool = False,
inject_default_instructions: bool = True,
prompt_dir: str | None = None,
prompt_config_path: str | None = None,
admin_token: str | None = None,
) -> Flask:
app = Flask(__name__)
prompt_manager = get_prompt_manager(
prompt_dir=prompt_dir,
prompt_config_path=prompt_config_path,
reset=True,
)

app.config.update(
VERBOSE=bool(verbose),
Expand All @@ -32,18 +45,103 @@ def create_app(
REASONING_COMPAT=reasoning_compat,
FAST_MODE=bool(fast_mode),
DEBUG_MODEL=debug_model,
BASE_INSTRUCTIONS=BASE_INSTRUCTIONS,
GPT5_CODEX_INSTRUCTIONS=GPT5_CODEX_INSTRUCTIONS,
EXPOSE_REASONING_MODELS=bool(expose_reasoning_models),
DEFAULT_WEB_SEARCH=bool(default_web_search),
INJECT_DEFAULT_INSTRUCTIONS=bool(inject_default_instructions),
PROMPT_MANAGER=prompt_manager,
ADMIN_TOKEN=(
admin_token
if isinstance(admin_token, str) and admin_token
else os.getenv("CHATMOCK_ADMIN_TOKEN") or os.getenv("CHATGPT_LOCAL_ADMIN_TOKEN") or None
),
)

@app.get("/")
@app.get("/health")
def health():
return jsonify({"status": "ok"})

def _require_local_admin():
remote_addr = request.remote_addr
allow_admin_external = os.getenv("CHATMOCK_ALLOW_ADMIN_EXTERNAL", "false").lower() == "true"
allowed_local_addresses = {"127.0.0.1", "::1"}
allowed_bridge_addresses = {"172.17.0.1", "172.18.0.1"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Hardcoding specific Docker bridge gateway addresses like 172.17.0.1 and 172.18.0.1 is fragile, as Docker network ranges can vary significantly depending on the host configuration and the number of existing networks. While the CHATMOCK_ALLOW_ADMIN_EXTERNAL escape hatch exists, consider allowing a configurable list of CIDR ranges or trusted gateway addresses via an environment variable to make this more robust for diverse container environments.

trusted_remote_spec = os.getenv("CHATMOCK_ADMIN_TRUSTED_IPS", "").strip()

def _is_in_trusted_ranges(value: str | None) -> bool:
if not isinstance(value, str) or not value or not trusted_remote_spec:
return False
try:
remote_ip = ipaddress.ip_address(value)
except ValueError:
return False
for token in (item.strip() for item in trusted_remote_spec.split(",")):
if not token:
continue
try:
if "/" in token:
if remote_ip in ipaddress.ip_network(token, strict=False):
return True
elif remote_ip == ipaddress.ip_address(token):
return True
except ValueError:
continue
return False
Comment on lines +64 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The _require_local_admin function parses environment variables and IP ranges on every request. This is inefficient, especially for the CHATMOCK_ADMIN_TRUSTED_IPS list which involves creating ip_network objects repeatedly. These configurations should be read and parsed once during application initialization (within the create_app scope but outside the request handler).

    allow_admin_external = os.getenv("CHATMOCK_ALLOW_ADMIN_EXTERNAL", "false").lower() == "true"
    trusted_networks = []
    for token in (t.strip() for t in os.getenv("CHATMOCK_ADMIN_TRUSTED_IPS", "").split(",") if t.strip()):
        try:
            trusted_networks.append(ipaddress.ip_network(token, strict=False))
        except ValueError:
            continue

    def _require_local_admin():
        remote_addr = request.remote_addr
        allowed_local_addresses = {"127.0.0.1", "::1"}
        allowed_bridge_addresses = {"172.17.0.1", "172.18.0.1"}

        def _is_in_trusted_ranges(value: str | None) -> bool:
            if not isinstance(value, str) or not value or not trusted_networks:
                return False
            try:
                remote_ip = ipaddress.ip_address(value)
                return any(remote_ip in net for net in trusted_networks)
            except ValueError:
                return False


is_allowed_bridge = isinstance(remote_addr, str) and remote_addr in allowed_bridge_addresses
is_local = isinstance(remote_addr, str) and remote_addr in allowed_local_addresses
is_trusted_remote = _is_in_trusted_ranges(remote_addr)
if not (is_local or is_allowed_bridge or is_trusted_remote or allow_admin_external):
return jsonify({"error": {"message": "Admin routes are local-only"}}), 403
expected_token = app.config.get("ADMIN_TOKEN")
provided_token = (
request.headers.get("X-ChatMock-Admin-Token")
or request.headers.get("X-Admin-Token")
or request.headers.get("Authorization", "").removeprefix("Bearer ").strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-medium medium

The Authorization header extraction is quite permissive. While removeprefix("Bearer ") handles standard Bearer tokens, it also allows tokens to be passed without the prefix if they don't start with "Bearer ". Consider strictly enforcing the "Bearer " prefix if that is the intended authentication scheme for the Authorization header.

)
if allow_admin_external and not (is_local or is_allowed_bridge or is_trusted_remote):
if not (isinstance(expected_token, str) and expected_token):
return jsonify({"error": {"message": "External admin access requires CHATMOCK_ADMIN_TOKEN"}}), 403
if isinstance(expected_token, str) and expected_token:
if not isinstance(provided_token, str) or not hmac.compare_digest(provided_token, expected_token):
return jsonify({"error": {"message": "Invalid admin token"}}), 403
Comment on lines +105 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require auth for loopback admin prompt endpoints

The new admin routes are writable (POST /admin/prompts/config and /admin/prompts/reload), but this check only enforces a token when ADMIN_TOKEN is configured, so loopback callers are unauthenticated by default. Because CORS currently reflects arbitrary origins in chatmock/http.py, a user merely visiting a malicious website can issue cross-origin requests to http://127.0.0.1:8000/admin/prompts/* and mutate runtime prompt config. This introduces a local CSRF-style privilege bypass for production defaults unless a token is always required (or these routes are exempted from permissive CORS).

Useful? React with 👍 / 👎.

return None

@app.get("/admin/prompts")
def admin_prompts_state():
denied = _require_local_admin()
if denied is not None:
return denied
return jsonify(prompt_manager.as_dict())

@app.post("/admin/prompts/reload")
def admin_prompts_reload():
denied = _require_local_admin()
if denied is not None:
return denied
try:
prompt_manager.reload()
except (FileNotFoundError, ValueError, OSError, PermissionError) as exc:
return jsonify({"error": {"message": str(exc)}}), 400
return jsonify(prompt_manager.as_dict())
Comment on lines +117 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The admin_prompts_reload endpoint does not catch potential exceptions from prompt_manager.reload(), such as FileNotFoundError or ValueError (e.g., if a prompt file was deleted or became empty since the last config update). This could result in an unhandled 500 error. It should implement error handling similar to admin_prompts_config.


@app.post("/admin/prompts/config")
def admin_prompts_config():
denied = _require_local_admin()
if denied is not None:
return denied
try:
payload = request.get_json(silent=False)
except (BadRequest, UnsupportedMediaType):
return jsonify({"error": {"message": "Invalid JSON body"}}), 400
if not isinstance(payload, dict):
return jsonify({"error": {"message": "Invalid JSON body"}}), 400
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
prompt_manager.update_config(payload)
except (FileNotFoundError, ValueError, OSError, PermissionError) as exc:
return jsonify({"error": {"message": str(exc)}}), 400
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return jsonify(prompt_manager.as_dict())

@app.after_request
def _cors(resp):
for k, v in build_cors_headers().items():
Expand Down
Loading