mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7da0559f3 | ||
|
|
9bf5d816ae | ||
|
|
4acdf3bb26 | ||
|
|
13dde2da15 | ||
|
|
a908e1ec17 | ||
|
|
9784bb192c | ||
|
|
44d755fb2e | ||
|
|
ce279d77d3 | ||
|
|
41e10456d7 | ||
|
|
43424404ee | ||
|
|
cb13a22673 | ||
|
|
8beb9c50d6 | ||
|
|
4be2fc5c9a | ||
|
|
d0d153b768 | ||
|
|
2419038884 | ||
|
|
e6e9b2f978 | ||
|
|
248d9d9673 | ||
|
|
65bdc5d73c | ||
|
|
fc35dddee8 | ||
|
|
668e099dd8 | ||
|
|
04777513b6 | ||
|
|
90e7b3ef55 | ||
|
|
b100848ebf | ||
|
|
0093781c8b | ||
|
|
e94471609f | ||
|
|
77e2c5f9fa | ||
|
|
41fe40af07 | ||
|
|
0d9602dc56 | ||
|
|
5e872ccc15 | ||
|
|
d3c4d21086 | ||
|
|
69831f5cdf | ||
|
|
3322ecd6a6 | ||
|
|
7b2a5fb05e | ||
|
|
2d373ae77a | ||
|
|
fc08133ab2 | ||
|
|
334f6fdddc | ||
|
|
a20628d334 | ||
|
|
a6e3fd0a2e | ||
|
|
ddddddd1f3 | ||
|
|
f3f9b8d4fd | ||
|
|
23cf14cf0a | ||
|
|
5bad722593 | ||
|
|
2aab3e8210 | ||
|
|
d45f0c7811 | ||
|
|
e6f4de5ddc | ||
|
|
ac601b7e59 | ||
|
|
f6583b4bad | ||
|
|
9ebc8b6703 | ||
|
|
1c35c9cf68 | ||
|
|
1ed154e86c | ||
|
|
4034ce4562 | ||
|
|
934568f336 | ||
|
|
7f8c7cbcae | ||
|
|
9e63a8031b | ||
|
|
978f308a9e | ||
|
|
80a1ee96f6 |
@@ -5,6 +5,11 @@ web/out
|
||||
server/webui/dist
|
||||
/artex
|
||||
data
|
||||
keys
|
||||
backups
|
||||
.env
|
||||
config.json
|
||||
jwt.key
|
||||
*.db
|
||||
transcripts
|
||||
bench/kv
|
||||
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
# Docker deployment configuration (copy to .env)
|
||||
ARTEX_TAG=latest
|
||||
# The image is built locally by `docker compose build`; there is no registry tag.
|
||||
# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0
|
||||
# to reach it from another machine or from Windows when ARTEX runs inside WSL.
|
||||
ARTEX_BIND_ADDR=127.0.0.1
|
||||
# Reverse proxies (comma-separated CIDRs) whose X-Forwarded-For the login rate
|
||||
# limit trusts. Empty: the header is ignored. See "Reverse proxy deployment" in README.md.
|
||||
ARTEX_TRUSTED_PROXIES=
|
||||
POSTGRES_USER=artex
|
||||
POSTGRES_PASSWORD=change-me-please
|
||||
POSTGRES_DB=artex
|
||||
|
||||
@@ -44,6 +44,26 @@ jobs:
|
||||
# as rotating failures in the agent package). Serial package execution
|
||||
# keeps the database state deterministic.
|
||||
run: go test -p 1 ./...
|
||||
- name: go test -race
|
||||
# Race detector on the packages that pass under -race today. ./server is
|
||||
# left out until it has been checked with -race against postgres.
|
||||
# Its own database: the plain run above leaves rows behind and the db tests
|
||||
# are not idempotent across runs (duplicate llm_profiles names).
|
||||
env:
|
||||
ARTEX_PG_DSN: postgres://artex:testpass@localhost:5432/artextest_race?sslmode=disable
|
||||
|
||||
run: >-
|
||||
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+5
-1
@@ -47,6 +47,7 @@ Thumbs.db
|
||||
|
||||
# JWT signing key (generated at runtime, stored in the project root, never committed)
|
||||
/jwt.key
|
||||
/keys/
|
||||
|
||||
# local environment generated by Docker deployment (contains passwords/keys, not committed)
|
||||
.env
|
||||
@@ -57,7 +58,10 @@ Thumbs.db
|
||||
# bench benchmark related (local artifacts, not committed)
|
||||
/bench/
|
||||
/Dockerfile.bench
|
||||
/Dockerfile.bench.v2backups/
|
||||
/Dockerfile.bench.v2
|
||||
|
||||
# scripts/backup.sh output (database dumps, data archives)
|
||||
backups/
|
||||
|
||||
# posture probe raw output
|
||||
posture-runs/
|
||||
|
||||
+86
-4
@@ -4,13 +4,92 @@ All notable changes to this project are documented in this file. The format is b
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional custom User-Agent for LLM requests**: an LLM profile can set a `User-Agent` value that overrides the header on every outbound LLM request; left empty, the default User-Agent is unchanged. The value is trimmed and rejected if it contains control characters or exceeds 256 bytes. Part of #154 (the fixed-full-URL mode is not included).
|
||||
|
||||
### Docs
|
||||
|
||||
- **LLM base URL note in the README**: for the Anthropic format, enter the host without a `/v1` suffix (the provider appends `/v1/messages`, so a `/v1` base becomes `/v1/v1/messages`); OpenAI-format providers append `/chat/completions`, so their base does end in `/v1`. Follow-up to #154.
|
||||
|
||||
## [0.5.1] - 2026-11-05
|
||||
|
||||
Login rate limiting per client IP with trusted-proxy support, a 12-character minimum for new passwords, a loopback-only default for the binary, and the JWT signing key in its own Docker volume.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The binary listens on `127.0.0.1:8787` by default**: the `-addr` default was `:8787`, every interface. Running `artex` or `./start.sh` without `-addr` now accepts connections from the local machine only; pass `-addr :8787` to listen on every interface. Docker is unchanged: the image starts the server with `-addr :8787` inside the container, and `docker-compose.yml` already publishes it on `127.0.0.1` by default (`ARTEX_BIND_ADDR`).
|
||||
- **New passwords need at least 12 characters** (was 8), at first-run setup (`POST /api/auth/init` and the `/setup` page) and in `POST /api/auth/change-password`. Login does not check length, so a password set under the old minimum keeps working until it is changed. `reset-password.sh` enforces the same minimum.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The JWT signing key survives container rebuilds**: the server wrote `jwt.key` to the executable's directory, `/app` in the image, so the key lived in the container's writable layer and recreating the container (for example `docker compose up -d --build`) generated a new key and signed every user out. The key directory is now set by `ARTEX_JWT_KEY_DIR` (default: the executable's directory, unchanged for non-Docker installs). `docker-compose.yml` sets it to `/app/keys` and bind-mounts `./keys` there, outside `./data`, which the file manager browses. On start, when `<key dir>/jwt.key` is missing, a key at `data/jwt.key`, then at the executable's directory, is copied there with mode 0600 and the old file is removed; otherwise a new key is generated. An existing key is never overwritten. Upgrading a Docker install signs every user out once, because the old key is in the container being replaced; to keep sessions, copy it out before recreating the container:
|
||||
|
||||
```bash
|
||||
mkdir -p keys
|
||||
docker cp artex-artex-1:/app/jwt.key ./keys/jwt.key
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- **Login rate limit**: `POST /api/auth/login` allows 10 attempts per minute per client IP. The next attempt gets 429 with a `Retry-After` header (seconds) and a JSON error, and the IP is blocked for 1 minute. Each attempt while blocked, and each later block, doubles the wait, up to 15 minutes. A successful login clears the IP's count. There is no per-account lockout, so failing logins cannot lock the admin out from other addresses. The state is in memory: a restart clears it, an IP idle for 15 minutes is dropped, and at most 10,000 IPs are tracked (the least recently seen is dropped first). The IP is the TCP peer address, and `X-Forwarded-For` is ignored, unless the peer is listed in the new `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default, passed through by `docker-compose.yml`). For a listed peer the header is read from the right and the first address that is not a trusted proxy is the client; entries the client added further left are never used. Behind a reverse proxy, set it: otherwise every client has the proxy's address and shares one budget, and an attacker failing logins through the proxy blocks the real users, admin included, for up to 15 minutes. The README nginx example now sends `X-Forwarded-For`.
|
||||
- **`GET /api/health` hides the version from anonymous callers**: without a valid token it answers `{"ok":true}` only. With a token (the web UI sends one for the version in its top bar) the answer is unchanged: `ok`, `service` and `version`.
|
||||
|
||||
## [0.5.0] - 2026-11-04
|
||||
|
||||
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
|
||||
|
||||
### Added
|
||||
|
||||
- **Ollama support**: an `ollama` LLM profile format for a local or LAN Ollama server. Keyless by design: no API key is needed, `OPENAI_API_KEY` is never inherited, the base URL defaults to `http://127.0.0.1:11434/v1` and is normalized to the `/v1` root, and the profile works in the failover pool, task LLM chains, model listing and connection tests. Contributed by @glauberportella (#128).
|
||||
- **Setup token for first-run setup**: while no admin password is set, the server generates a random 128-bit one-time token at startup and prints it to its log (`docker compose logs artex | grep "Setup token"`). The `/setup` page has a field for it, `POST /api/auth/init` requires it (`setup_token`, compared in constant time, 403 when missing or wrong), and it is cleared once the password is stored (#137).
|
||||
- **`ARTEX_BIND_ADDR`**: the address `docker-compose.yml` publishes the web UI on, default `127.0.0.1` (#137).
|
||||
|
||||
### Security
|
||||
|
||||
- **First-run claim**: before, anyone who reached port 8787 on a fresh install before the operator could set the admin password. Init now needs the setup token above, and Docker installs publish 8787 on `127.0.0.1` by default; set `ARTEX_BIND_ADDR=0.0.0.0` in `.env`, or use a reverse proxy or SSH tunnel, for remote access (#137).
|
||||
- **Startup secrets removed from the environment**: `ARTEX_PG_DSN`, `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are unset inside `server.New` after they are read and before the MCP discovery goroutine and the task resume loop start, so agent shells, MCP stdio servers and custom tools spawned afterwards no longer inherit them (#136). The binary passes the option that enables this, but `New` did not apply its options, so the scrub from #136 never ran until #146 fixed it (#146). See Known issues for what this does not cover.
|
||||
- **Saved API keys go only to their own endpoint**: `POST /api/llm/test` and `POST /api/llm/models` used a saved key (the profile's, or the global one) for any `base_url` in the request body, so a crafted request could send it to another host. A saved key is now sent only when the request's provider and normalized base URL match the ones it was saved for; otherwise the request must carry `api_key` (#142).
|
||||
- **`ARTEX_LLM_PROVIDER` is matched case-insensitively**: `Ollama` or a value with surrounding spaces fell through to the Anthropic default and sent `ANTHROPIC_API_KEY` to the configured base URL (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat (direct commit 1c35c9c).
|
||||
- **Malformed `config.json` is an error**: `config.Load` used to ignore a parse error and treat the file as missing, which surfaced as a misleading "no database config found" or a silently ignored `skill_dir`. A read or parse error is now returned with the file path and startup exits (#141).
|
||||
- **`install.sh` escapes what it writes**: the local install writes `config.json` string values through a JSON escaper and requires a numeric port; the Docker install escapes `\`, `|` and `&` in the Postgres password and API key before the `.env` substitution (#141).
|
||||
- **`scripts/backup.sh --keep 0` is rejected**: it used to delete every dump, including the one just written, and still report success. Values with a leading zero are rejected too (#139).
|
||||
- **`backups/` is ignored again**: a missing newline had merged it into the previous `.gitignore` line, so database dumps showed up as untracked files (#138).
|
||||
|
||||
### Build and CI
|
||||
|
||||
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally (direct commit 1ed154e).
|
||||
- **A fresh clone builds the image**: the Dockerfile copied a prebuilt `dist/<arch>/artex` that a clean checkout does not have. It is now a multi-stage build: a Node 22 stage runs the frontend static export, a Go 1.26 stage runs `build.sh` with the release binaries job's flags (CGO-free cross-compile, `-trimpath`, version stamp), and the runtime stage copies the result (#147).
|
||||
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete" (direct commit 1ed154e).
|
||||
- **CI runs the race detector and shellcheck**: `go test -race -p 1` over every package with tests except `./server`, and `shellcheck -S warning` on `install.sh`, `update.sh`, `start.sh` and `scripts/backup.sh` (#144). The race run uses its own database (direct commit 0477751).
|
||||
|
||||
### Upgrades
|
||||
|
||||
- **Go 1.26.10** in `go.mod` (was 1.26.3), and **`golang.org/x/net` v0.62.0** (was v0.58.0), which raised x/crypto to 0.58.0, x/text to 0.43.0, x/sys to 0.49.0, x/sync to 0.24.0, x/mod to 0.41.0 and x/tools to 0.51.0 (#145).
|
||||
- **Node 22 in the runtime image**: NodeSource `setup_22.x` instead of the end-of-life `setup_20.x`, matching CI and the release workflow (#143).
|
||||
|
||||
### Known issues
|
||||
|
||||
- **The secret scrub has limits**: `os.Unsetenv` changes only the Go runtime's copy, so a process running as the same user can still read `/proc/<pid>/environ` of the server (and, in Docker, of PID 1, `start.sh`). Other variables that may carry credentials, such as `ARTEX_LLM_PROXY`, are not scrubbed.
|
||||
- **The LLM test and model-list endpoints still take `proxy` from the request**, so a saved key can travel through a request-supplied proxy when the base URL is plain HTTP.
|
||||
- **Postgres passwords with URL-special characters** (`@`, `/`, `#`, `?`) break the `ARTEX_PG_DSN` that `docker-compose.yml` builds by interpolation, and Compose interpolates `$` in `.env`.
|
||||
- **The build context includes local secrets**: `.dockerignore` does not exclude `.env`, `config.json` or `jwt.key`, so the build stage's `COPY . .` puts them in a build-stage layer of the local builder cache. They are not copied into the final image.
|
||||
- **Image supply chain**: the recon tools are downloaded at pinned versions but not checksum-verified, sqlmap tracks its default branch, and the Playwright packages are installed at `@latest`. Draft #127 proposes checksum verification.
|
||||
- **No Docker image is published for this release**: the release workflow's `docker` job only runs when `DOCKERHUB_ENABLED` is set, and it still downloads prebuilt binaries into `dist/` that the Dockerfile no longer uses. Build the image from the checkout with `docker compose up -d --build`.
|
||||
- **CI gaps**: `./server` is not run under `-race`; there is no `govulncheck` step and no `docker build` job. Four tests skip themselves (fresh-database or timing failures, see #9).
|
||||
- **Reported and not fixed in this release**: `list_task_credentials` emits an invalid `"required": [""]` schema that strict LLM providers reject (#130), and the MCP Streamable HTTP client fails against servers that only support protocol version 2025-11-25 (#131; fix proposed in #132).
|
||||
|
||||
## [0.4.0] - 2026-10-23
|
||||
|
||||
First release of this fork. The engine underneath is still upstream's, but this tree carries a long stretch of operational work on top of it: a recon toolbox and skills, findings discipline, cost accounting, and container packaging.
|
||||
|
||||
### Added
|
||||
|
||||
- **Recon toolbox in the image**: pinned nuclei, httpx, subfinder, katana, ffuf, dalfox and a sqlmap checkout, verified at build time, so hunt workers can call them directly (#112).
|
||||
- **Recon toolbox in the image**: nuclei, httpx, subfinder, katana, ffuf and dalfox installed at release-pinned versions, plus a sqlmap checkout that tracks the default branch, each smoke-tested at build time (run once, not checksum-verified), so hunt workers can call them directly (#112). Checksum verification arrives with #127.
|
||||
- **Recursive recon skill** (`bbot-recon`): bbot v3.0.2 in its own virtualenv with module dependencies preinstalled. Policy-gated modes (passive seeds-only when scanners are banned, active-safe, loud only on explicit permission) and JSONL ingestion into the facts graph (#118).
|
||||
- **php2shell skill**: turns a file-inclusion finding into verified RCE through the session.upload_progress chain, lab first and gated on program policy (#113).
|
||||
- **Code audit and reverse-engineering skills** (#55, #77).
|
||||
@@ -30,9 +109,10 @@ First release of this fork. The engine underneath is still upstream's, but this
|
||||
- **Planner quiet mode** for less narration during long runs (#81, #88).
|
||||
- **Findings discipline**: proof-basis gates, measured impact, adversarial verification and dedup rules decide what a finding must contain before it is reported (#51, #53, #63, #69).
|
||||
- **Prompt-injection hardening** on seed material and MCP tool descriptions (#49).
|
||||
- **Pause preserves the task timeout budget**, and paused time is frozen in the task clock (#2, #65, #110).
|
||||
- **Pause preserves the task timeout budget**, and paused time is frozen in the task clock (#2, #110).
|
||||
- **Deadline contract** in the executor seeds (#65).
|
||||
- **Task detail metrics and outcome cards** summarise a finished run (#92, #102).
|
||||
- **Skill-gap, loop-contract and shadow-lesson reporting** for the planner (#104, #106, #108).
|
||||
- **Skill-gap and loop-contract reporting** for the planner (#104, #106); the RSI notes land as documentation in #108.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -920,7 +1000,9 @@ First release of this fork. The engine underneath is still upstream's, but this
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...HEAD
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.1...HEAD
|
||||
[0.5.1]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/BaaRai/ARTEX/compare/v0.3.15...v0.4.0
|
||||
[0.3.10]: https://github.com/Autumn-27/ARTEX/compare/v0.3.9...v0.3.10
|
||||
[0.3.9]: https://github.com/Autumn-27/ARTEX/compare/v0.3.8...v0.3.9
|
||||
|
||||
+51
-17
@@ -1,24 +1,58 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Runtime image (no compilation inside the image): install common tools only and ship
|
||||
# a **pre-built Linux single binary**. The binary is cross-compiled by the CI binaries
|
||||
# job (pure Go, no QEMU) and placed at dist/<TARGETARCH>/artex in the build context.
|
||||
# This way multi-arch builds only need to emulate the apt layer for arm64 instead of
|
||||
# emulating the Next/Go compilation, which is much faster.
|
||||
# Multi-stage build: `docker compose up -d --build` (or `docker build .`) produces
|
||||
# the complete image from source. No prebuilt binary in the build context is needed,
|
||||
# so a fresh `git clone` builds — dist/ is git-ignored and is no longer required.
|
||||
#
|
||||
# To build the image manually, prepare the binary yourself first:
|
||||
# cd web && npm run build:static && cd ..
|
||||
# cp -r web/out server/webui/dist
|
||||
# CGO_ENABLED=0 GOARCH=amd64 go build -tags embedui -o dist/amd64/artex ./cmd/artex
|
||||
# docker build -t artex:local .
|
||||
# frontend (web) -> Go binary with embedded UI (build) -> runtime image
|
||||
#
|
||||
# The Go binary is produced by build.sh with the same flags the release workflow's
|
||||
# binaries job uses, so the image binary and the published release binary come from
|
||||
# one source.
|
||||
|
||||
# ---------------------------------------------------------------- frontend ---
|
||||
# Pinned to the Node 22 line the CI/release workflows use. On the build platform
|
||||
# only; the frontend static export is architecture-independent.
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY web/ ./
|
||||
RUN npm run build:static
|
||||
|
||||
# ------------------------------------------------------------- Go binary ---
|
||||
# Pinned to the Go 1.26 line (matches go.mod and the release binaries job). The
|
||||
# binary is CGO-free, so this stage runs on the build platform and cross-compiles
|
||||
# to $TARGETARCH without QEMU.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Reuse the frontend built above; build.sh skips its own frontend step.
|
||||
COPY --from=web /src/web/out ./server/webui/dist
|
||||
ARG ARTEX_VERSION=dev
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
# Same flags as the release binaries job (ARTEX_COMPRESS=0, -trimpath,
|
||||
# -X main.version), driven through build.sh so there is one build path.
|
||||
RUN ARTEX_SKIP_FRONTEND=1 \
|
||||
ARTEX_SKIP_NPM_CI=1 \
|
||||
ARTEX_COMPRESS=0 \
|
||||
ARTEX_OUTPUT=/out/artex \
|
||||
ARTEX_BUILD_VERSION="${ARTEX_VERSION}" \
|
||||
bash ./build.sh --target "${TARGETOS:-linux}/${TARGETARCH:-amd64}"
|
||||
|
||||
# ---------------------------------------------------------------- runtime ---
|
||||
FROM python:3.12-slim-bookworm
|
||||
ARG TARGETARCH
|
||||
# Common tools: ripgrep / curl / vim, plus a set of recon staples (add/remove as needed).
|
||||
# Node 20.x comes from NodeSource: bookworm's apt nodejs is 18, but Playwright requires >=20.
|
||||
# Node 22.x comes from NodeSource: bookworm's apt nodejs is 18, Playwright requires >=20,
|
||||
# and 20 is end-of-life. 22 matches the CI and release workflows.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates ripgrep curl wget vim git jq unzip \
|
||||
dnsutils iputils-ping netcat-openbsd inetutils-telnet whois nmap \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Web-recon toolbox (drost-community take 1, #100): pinned release binaries installed
|
||||
@@ -83,16 +117,16 @@ RUN npm install -g @playwright/mcp@latest @playwright/cli@latest playwright@late
|
||||
&& playwright install --with-deps chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# pre-built binary for the matching architecture (dist/amd64/artex or dist/arm64/artex)
|
||||
COPY dist/${TARGETARCH}/artex /app/artex
|
||||
# Binary compiled from source in the build stage above.
|
||||
COPY --from=build /out/artex /app/artex
|
||||
# Daemon startup script: relaunches the process after a crash and forwards SIGTERM
|
||||
# to artex — docker stop only signals PID 1; without forwarding, artex never receives it,
|
||||
# cannot shut down gracefully, and is hard-killed by SIGKILL after 10 seconds.
|
||||
COPY start.sh /app/start.sh
|
||||
RUN chmod +x /app/artex /app/start.sh
|
||||
RUN chmod +x /app/artex /app/start.sh && mkdir -p /app/keys
|
||||
COPY skills/ /app/skills/
|
||||
# persistence point for data/ (SQLite + jwt.key)
|
||||
VOLUME ["/app/data"]
|
||||
# persistence points: data/ (SQLite, task files) and keys/ (jwt.key, via ARTEX_JWT_KEY_DIR)
|
||||
VOLUME ["/app/data", "/app/keys"]
|
||||
EXPOSE 8787 8788
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
CMD ["-addr", ":8787", "-proxy", ":8788"]
|
||||
|
||||
@@ -9,6 +9,8 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
|
||||
|
||||
</div>
|
||||
|
||||
<a href="https://trendshift.io/repositories/316805?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-316805" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/316805/daily?language=Go" alt="BaaRai%2FARTEX | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -84,10 +86,13 @@ cd ARTEX
|
||||
|
||||
The script will: detect / auto-install Docker → then let you choose **① all-Docker** or **② local build and run**:
|
||||
|
||||
- **① All-Docker**: enter a Postgres password (press Enter for a random one) → auto-writes `.env` → `docker compose up -d`.
|
||||
- **① All-Docker**: enter a Postgres password (press Enter for a random one) → auto-writes `.env` → `docker compose up -d --build` (the image is built from this checkout).
|
||||
- **② Local run**: choose a database (connect to an existing one / start one with Docker) → generates `config.json` → `go` builds the embedded single binary → starts.
|
||||
|
||||
Once installed, open **http://localhost:8787** (on first entry, `/setup` sets the admin password).
|
||||
`/setup` asks for a one-time setup token: while no password is set, the backend prints it at startup in its log (`docker compose logs artex | grep "Setup token"`, or the terminal running `./start.sh`).
|
||||
|
||||
The web UI is published on `127.0.0.1` by default. To reach it from another machine, or from Windows when ARTEX runs inside WSL, set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the first-run claim), or keep loopback and use a reverse proxy or an SSH tunnel.
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
@@ -95,10 +100,13 @@ Once installed, open **http://localhost:8787** (on first entry, `/setup` sets th
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
docker compose up -d # pulls the autumn27/artex image + postgres
|
||||
docker compose up -d --build # builds artex from this checkout + starts postgres
|
||||
# → http://localhost:8787
|
||||
```
|
||||
|
||||
> The image is built locally. There is no published image to pull: the previous
|
||||
> default pointed at a third-party namespace that no longer hosts this project.
|
||||
|
||||
The image already includes common tools (ripgrep/curl/vim/npm/nmap…); `./skills` and `./data` are persisted via bind mounts.
|
||||
|
||||
For remote MCP, you can choose `http` (Streamable HTTP) or `sse` (legacy SSE) in system settings.
|
||||
@@ -173,7 +181,20 @@ Daily cron example (03:20, operator crontab):
|
||||
|
||||
## Updating and upgrading
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (jwt.key / SQLite, etc.), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
### Upgrading from a clone made before 2026-10-23
|
||||
|
||||
The repository history was rewritten that day to drop a personal email address from the commit metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of silently rebuilding stale code. Recover once, then upgrade normally:
|
||||
|
||||
```bash
|
||||
git stash # only if you have local changes
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git stash pop # only if you stashed
|
||||
```
|
||||
|
||||
Your data is not affected: `./data`, the `pgdata` volume and `./skills` are not part of the git history.
|
||||
|
||||
### Option 1: one-click update script
|
||||
|
||||
@@ -184,17 +205,16 @@ cd ARTEX
|
||||
|
||||
The script first optionally `git pull`s the latest code, then lets you choose **① Docker update** or **② local build update** (matching `install.sh`):
|
||||
|
||||
- **① Docker**: you can specify a target image tag (press Enter to keep `.env`'s `ARTEX_TAG`, default `latest`) → `docker compose pull` → `docker compose up -d` (restarting with the new image migrates automatically).
|
||||
- **① Docker**: rebuilds the image from this checkout → `docker compose build artex` → `docker compose up -d artex` (restarting migrates the schema automatically).
|
||||
- **② Local**: rebuild the frontend static assets → recompile `./artex` (restart the process for it to take effect).
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
git pull # update compose / scripts (optional)
|
||||
# pin a version: set ARTEX_TAG=v0.2.0 in .env; otherwise latest is used
|
||||
docker compose pull artex
|
||||
docker compose up -d artex # restart with the new image → schema migrates automatically
|
||||
git pull # update compose / scripts
|
||||
docker compose build artex # rebuild the image from this checkout
|
||||
docker compose up -d artex # recreate the container → schema migrates automatically
|
||||
docker image prune -f # clean up old images (optional)
|
||||
```
|
||||
|
||||
@@ -233,8 +253,10 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
}
|
||||
```
|
||||
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page.
|
||||
Optional: `ARTEX_LLM_PROVIDER` / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server).
|
||||
Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
|
||||
**Base URL**: for the Anthropic format, enter the host **without** a `/v1` suffix (e.g. `https://api.anthropic.com`) — the provider appends `/v1/messages` itself, so a base ending in `/v1` becomes `https://api.anthropic.com/v1/v1/messages`. OpenAI-format providers append `/chat/completions`, so their base **does** end in `/v1` (e.g. `https://api.openai.com/v1`).
|
||||
|
||||
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
|
||||
|
||||
@@ -242,7 +264,7 @@ Optional: `ARTEX_LLM_PROVIDER` / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ART
|
||||
|
||||
### Reverse proxy deployment (HTTPS / only exposing 443)
|
||||
|
||||
The frontend and API/SSE are served by the same backend port (default `:8787`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
The frontend and API/SSE are served by the same backend port (8787; the binary listens on `127.0.0.1:8787` by default, the Docker container on `:8787` published on `127.0.0.1`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
|
||||
SSE is a long-lived connection with continuous pushes, so the reverse proxy **must disable buffering**, otherwise the browser connects but never receives events (the activity stream spins forever). Nginx example:
|
||||
|
||||
@@ -256,6 +278,7 @@ server {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# SSE essentials: buffering off, long timeouts, HTTP/1.1
|
||||
proxy_buffering off;
|
||||
@@ -267,6 +290,13 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a proxy every request comes from the proxy's address, so without further setup all users share one budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes. To avoid that, keep the `X-Forwarded-For` line above and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default) to the address ARTEX sees the proxy connect from:
|
||||
|
||||
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`. Start ARTEX with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
|
||||
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with `docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network is `<project directory>_default`; `docker network ls` lists it), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, and run `docker compose up -d`.
|
||||
|
||||
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address that is not a trusted proxy; from any other peer the header is ignored. Trust only addresses of proxies you run: a trusted peer can claim any client address.
|
||||
|
||||
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set `NEXT_PUBLIC_SSE_BASE` **at build time** (this variable is baked into the static bundle during `next build`; setting it at container runtime has no effect).
|
||||
|
||||
---
|
||||
|
||||
+136
-14
@@ -3,8 +3,10 @@
|
||||
// §4.3 (planner) and §4.4 (work agent).
|
||||
//
|
||||
// Provider configuration is read from the environment so the system runs with
|
||||
// any Anthropic- or OpenAI-format endpoint. If no key is configured, FromEnv
|
||||
// returns ok=false and the exploration engine stays idle (an LLM is required).
|
||||
// any Anthropic-, OpenAI- or Ollama-format endpoint (the last being keyless).
|
||||
// If no usable configuration is found — a key for the cloud formats, a base
|
||||
// URL for keyless Ollama — FromEnv returns ok=false and the exploration engine
|
||||
// stays idle (an LLM is required).
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -84,6 +86,10 @@ type Config struct {
|
||||
// context via transcript.WithSessionID and read by the RoundTripper, so even a
|
||||
// shared provider emits different header values per session.
|
||||
SessionHeaderKey string
|
||||
// UserAgent, when non-empty, overrides the User-Agent header on every outbound
|
||||
// LLM request built from this config. Empty = don't set it, leaving the Go/Norma
|
||||
// default UA untouched (the historical behavior). Validated by ValidateUserAgent.
|
||||
UserAgent string
|
||||
// Retry holds the retry parameters resolved for this config (profile override →
|
||||
// global policy → built-in default, resolved server-side). See RetryConfig for
|
||||
// the per-layer semantics; zero values = fully inherit the built-in defaults.
|
||||
@@ -125,6 +131,47 @@ const (
|
||||
maxWindowK = 1000 // cap at 1M tokens (user request)
|
||||
)
|
||||
|
||||
// FormatOllama marks a LOCAL Ollama server profile. Ollama exposes an
|
||||
// OpenAI-compatible Chat Completions endpoint (/v1), so on the wire it speaks
|
||||
// llm.FormatOpenAI; the distinct sentinel matters everywhere a profile must be
|
||||
// treated as usable WITHOUT an API key — Ollama runs keyless on localhost/LAN.
|
||||
// See NeedsAPIKey and NewProvider (which maps it onto FormatOpenAI while
|
||||
// forcing our base-URL/key defaults so an empty key never falls back to
|
||||
// OpenAI's public endpoint).
|
||||
const FormatOllama llm.Format = "ollama"
|
||||
|
||||
// DefaultOllamaBaseURL is the base of Ollama's OpenAI-compatible endpoint the
|
||||
// provider appends /chat/completions to. Must never end up empty: with an
|
||||
// empty BaseURL, the SDK would fall back to OpenAI's public endpoint.
|
||||
const DefaultOllamaBaseURL = "http://127.0.0.1:11434/v1"
|
||||
|
||||
// ollamaPlaceholderKey is sent as the Bearer token when no key is configured.
|
||||
// Ollama ignores authorization, but a concrete value keeps the SDK from
|
||||
// falling back to OPENAI_API_KEY (which would silently ship a real cloud key
|
||||
// to the local server) and keeps the header stable.
|
||||
const ollamaPlaceholderKey = "ollama"
|
||||
|
||||
// normalizeOllamaBaseURL maps whatever the operator typed onto the /v1 root the
|
||||
// OpenAI-compatible provider expects: it strips a pasted "/chat/completions"
|
||||
// suffix, an empty value becomes the local default, and a missing "/v1" is
|
||||
// appended so the natural "http://host:11434" also works. Shared by the saved
|
||||
// profile path (ConfigFrom) and the environment path (FromEnv): both used to
|
||||
// differ, and only the profile path normalized.
|
||||
func normalizeOllamaBaseURL(raw string) string {
|
||||
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSpace(raw), "/chat/completions"), "/")
|
||||
if b == "" {
|
||||
return DefaultOllamaBaseURL
|
||||
}
|
||||
if !strings.HasSuffix(b, "/v1") {
|
||||
b += "/v1"
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// NeedsAPIKey reports whether this provider format requires an API key to be
|
||||
// usable. Ollama (local server) is keyless; every cloud format needs one.
|
||||
func (c Config) NeedsAPIKey() bool { return c.Format != FormatOllama }
|
||||
|
||||
// CompactionWindow returns the model context window in TOKENS for compaction
|
||||
// thresholds, resolved from the user-configured size (ContextWindowK). 0/unset →
|
||||
// a 200K default; otherwise clamped to [32K, 1M] so compaction stays effective.
|
||||
@@ -154,13 +201,15 @@ func compactionConfig(windowTokens int) *compaction.Config {
|
||||
|
||||
// FromEnv resolves the LLM provider config:
|
||||
//
|
||||
// ARTEX_LLM_PROVIDER = anthropic|openai (default: inferred from keys)
|
||||
// ARTEX_LLM_PROVIDER = anthropic|openai|openai-responses|ollama (default: inferred from keys)
|
||||
// ARTEX_LLM_MODEL = model id (default: per provider)
|
||||
// ARTEX_LLM_BASE_URL = endpoint (optional)
|
||||
// ARTEX_LLM_BASE_URL = endpoint (optional; ollama defaults to localhost:11434/v1)
|
||||
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
|
||||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials
|
||||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama)
|
||||
func FromEnv() (Config, bool) {
|
||||
prov := os.Getenv("ARTEX_LLM_PROVIDER")
|
||||
// Case- and space-insensitive: "Ollama" used to miss every case below and
|
||||
// fall through to anthropic, sending ANTHROPIC_API_KEY to the Ollama base URL.
|
||||
prov := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_LLM_PROVIDER")))
|
||||
anthKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
oaiKey := os.Getenv("OPENAI_API_KEY")
|
||||
|
||||
@@ -196,6 +245,16 @@ func FromEnv() (Config, bool) {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
case "ollama":
|
||||
c.Format = FormatOllama
|
||||
// Deliberately NOT inheriting OPENAI_API_KEY: a local Ollama ignores the
|
||||
// bearer token, so the only thing the inheritance could do is ship a real
|
||||
// cloud key to the endpoint. A gateway that does require a key can still
|
||||
// be configured as a saved profile with an explicit one.
|
||||
c.BaseURL = normalizeOllamaBaseURL(c.BaseURL)
|
||||
if c.Model == "" {
|
||||
c.Model = "llama3.2"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
c.APIKey = anthKey
|
||||
@@ -203,7 +262,8 @@ func FromEnv() (Config, bool) {
|
||||
c.Model = "claude-opus-4-8"
|
||||
}
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
// Ollama runs without a key: only the cloud formats gate on one.
|
||||
if c.APIKey == "" && c.NeedsAPIKey() {
|
||||
return Config{}, false
|
||||
}
|
||||
return c, true
|
||||
@@ -236,6 +296,16 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
case "ollama":
|
||||
c.Format = FormatOllama
|
||||
// Ollama's OpenAI-compatible endpoint lives at /v1 and the provider
|
||||
// appends "/chat/completions". Never leave it empty: an empty BaseURL
|
||||
// would let the SDK fall back to OpenAI's public endpoint with this
|
||||
// profile's request.
|
||||
c.BaseURL = normalizeOllamaBaseURL(c.BaseURL)
|
||||
if c.Model == "" {
|
||||
c.Model = "llama3.2"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
// provider appends "/v1/messages".
|
||||
@@ -257,13 +327,16 @@ func isFalsy(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Provider returns the short provider name ("anthropic"/"openai").
|
||||
// Provider returns the short provider name
|
||||
// ("anthropic"/"openai"/"openai-responses"/"ollama").
|
||||
func (c Config) Provider() string {
|
||||
switch c.Format {
|
||||
case llm.FormatOpenAI:
|
||||
return "openai"
|
||||
case llm.FormatOpenAIResponses:
|
||||
return "openai-responses"
|
||||
case FormatOllama:
|
||||
return "ollama"
|
||||
}
|
||||
return "anthropic"
|
||||
}
|
||||
@@ -272,14 +345,31 @@ func (c Config) Provider() string {
|
||||
// limiter lives on the single provider instance — so planner + all workers +
|
||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||
func (c Config) NewProvider() (llm.Provider, error) {
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey, c.UserAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wireFormat := c.Format
|
||||
baseURL := c.BaseURL
|
||||
apiKey := c.APIKey
|
||||
// Ollama speaks OpenAI's Chat Completions on the wire. Bundle the keyless
|
||||
// defaults here so an Ollama config can never leak toward the SDK's env-
|
||||
// driven fallbacks: BaseURL to OpenAI's public endpoint, APIKey to
|
||||
// OPENAI_API_KEY. A local Ollama ignores the placeholder token; a gateway
|
||||
// that requires one carries a real key in the profile.
|
||||
if wireFormat == FormatOllama {
|
||||
wireFormat = llm.FormatOpenAI
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultOllamaBaseURL
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
apiKey = ollamaPlaceholderKey
|
||||
}
|
||||
}
|
||||
lc := llm.Config{
|
||||
Format: c.Format,
|
||||
BaseURL: c.BaseURL,
|
||||
APIKey: c.APIKey,
|
||||
Format: wireFormat,
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Model: c.Model,
|
||||
HTTPClient: client,
|
||||
}
|
||||
@@ -354,6 +444,9 @@ type quotaAwareTransport struct {
|
||||
// carries; its value is the session id read from the request context. Empty
|
||||
// disables it. See Config.SessionHeaderKey.
|
||||
sessionHeaderKey string
|
||||
// userAgent, when non-empty, is set as the User-Agent header on every request.
|
||||
// Empty leaves the default UA untouched. See Config.UserAgent.
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -366,6 +459,11 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
req.Header.Set(t.sessionHeaderKey, sid)
|
||||
}
|
||||
}
|
||||
// Custom User-Agent: override only when configured, so an empty value keeps the
|
||||
// underlying transport's default UA exactly as before.
|
||||
if t.userAgent != "" {
|
||||
req.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
// When LLM recording is on, the Recorder puts a Capture on the context so the
|
||||
// raw wire bodies can be persisted. This is the only layer that still sees
|
||||
// them: norma builds the request body internally and decodes the SSE response
|
||||
@@ -418,7 +516,7 @@ func requestBodySnapshot(req *http.Request) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
|
||||
func quotaAwareHTTPClient(proxy, sessionHeaderKey, userAgent string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
@@ -437,7 +535,31 @@ func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey)}}, nil
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey), userAgent: strings.TrimSpace(userAgent)}}, nil
|
||||
}
|
||||
|
||||
// MaxUserAgentLen bounds a custom User-Agent value. Generous for real UA strings
|
||||
// while keeping a pathological value out of every outbound request.
|
||||
const MaxUserAgentLen = 256
|
||||
|
||||
// ValidateUserAgent trims a custom User-Agent and returns the cleaned value. An
|
||||
// empty/blank input is valid and means "no override" (keep the default UA). A
|
||||
// non-empty value is rejected when it exceeds MaxUserAgentLen or contains any
|
||||
// control character (including CR/LF, which would allow header injection).
|
||||
func ValidateUserAgent(raw string) (string, error) {
|
||||
ua := strings.TrimSpace(raw)
|
||||
if ua == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(ua) > MaxUserAgentLen {
|
||||
return "", fmt.Errorf("user agent too long: %d bytes (max %d)", len(ua), MaxUserAgentLen)
|
||||
}
|
||||
for _, r := range ua {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", fmt.Errorf("user agent contains a control character")
|
||||
}
|
||||
}
|
||||
return ua, nil
|
||||
}
|
||||
|
||||
// logTestConnection prints the raw HTTP status code(s) and response body of a
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRoundTripCapturesRawBodies(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// ARTEX_LLM_PROVIDER is matched regardless of case and surrounding spaces. A
|
||||
// miss used to fall through to anthropic: "Ollama" then sent ANTHROPIC_API_KEY
|
||||
// to the Ollama base URL.
|
||||
func TestFromEnvProviderIsCaseInsensitive(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
|
||||
t.Setenv("OPENAI_API_KEY", "sk-oai")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "")
|
||||
t.Setenv("ARTEX_LLM_MODEL", "")
|
||||
for _, tc := range []struct {
|
||||
env string
|
||||
want llm.Format
|
||||
key string
|
||||
}{
|
||||
{"Ollama", FormatOllama, ""},
|
||||
{" OLLAMA ", FormatOllama, ""},
|
||||
{"OpenAI", llm.FormatOpenAI, "sk-oai"},
|
||||
{"OpenAI-Responses", llm.FormatOpenAIResponses, "sk-oai"},
|
||||
{"Anthropic", llm.FormatAnthropic, "sk-ant-secret"},
|
||||
} {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", tc.env)
|
||||
c, ok := FromEnv()
|
||||
if !ok {
|
||||
t.Fatalf("%q: FromEnv returned ok=false", tc.env)
|
||||
}
|
||||
if c.Format != tc.want || c.APIKey != tc.key {
|
||||
t.Fatalf("%q: format=%v key=%q, want format=%v key=%q", tc.env, c.Format, c.APIKey, tc.want, tc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serveUA spins up an httptest server that records the User-Agent of the request
|
||||
// it receives, drives one request through a quotaAwareHTTPClient built with the
|
||||
// given userAgent, and returns what actually reached the wire.
|
||||
func serveUA(t *testing.T, userAgent string) string {
|
||||
t.Helper()
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "", userAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("quotaAwareHTTPClient: %v", err)
|
||||
}
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return <-got
|
||||
}
|
||||
|
||||
func TestUserAgentHeaderSetWhenConfigured(t *testing.T) {
|
||||
const want = "artex-test-agent/1.2.3"
|
||||
if got := serveUA(t, want); got != want {
|
||||
t.Fatalf("User-Agent = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentHeaderDefaultWhenEmpty(t *testing.T) {
|
||||
// Empty = no override: the Go client's default UA must reach the server unchanged.
|
||||
got := serveUA(t, "")
|
||||
if !strings.HasPrefix(got, "Go-http-client/") {
|
||||
t.Fatalf("User-Agent = %q, want the default Go-http-client UA when unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentRejectsNewline(t *testing.T) {
|
||||
if _, err := ValidateUserAgent("good/1.0\r\nX-Injected: 1"); err == nil {
|
||||
t.Fatalf("expected a newline-containing user agent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// The rule is "no control character at all" (r < 0x20 or r == 0x7f), so the
|
||||
// non-newline controls are rejected too, TAB included: a header value is one
|
||||
// printable token, not something to lay out with tabs.
|
||||
func TestValidateUserAgentRejectsControlCharacters(t *testing.T) {
|
||||
for name, in := range map[string]string{
|
||||
"NUL": "good/1.0\x00",
|
||||
"US": "good/1.0\x1f",
|
||||
"DEL": "good/1.0\x7f",
|
||||
"TAB": "good/1.0\tX",
|
||||
} {
|
||||
if _, err := ValidateUserAgent(in); err == nil {
|
||||
t.Errorf("%s: expected a user agent containing %q to be rejected", name, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentAcceptsPrintableToken(t *testing.T) {
|
||||
const want = "MyTool/1.0 (contact-free)"
|
||||
if ua, err := ValidateUserAgent(" " + want + " "); err != nil || ua != want {
|
||||
t.Fatalf("printable value: got (%q, %v), want (%q, nil)", ua, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentRejectsTooLong(t *testing.T) {
|
||||
if _, err := ValidateUserAgent(strings.Repeat("a", MaxUserAgentLen+1)); err == nil {
|
||||
t.Fatalf("expected a user agent longer than %d to be rejected", MaxUserAgentLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentTrimsAndAllowsEmpty(t *testing.T) {
|
||||
if ua, err := ValidateUserAgent(" "); err != nil || ua != "" {
|
||||
t.Fatalf("blank user agent: got (%q, %v), want (\"\", nil)", ua, err)
|
||||
}
|
||||
if ua, err := ValidateUserAgent(" my-app/1.0 "); err != nil || ua != "my-app/1.0" {
|
||||
t.Fatalf("trim: got (%q, %v), want (\"my-app/1.0\", nil)", ua, err)
|
||||
}
|
||||
}
|
||||
+27
-3
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -49,7 +50,7 @@ func main() {
|
||||
|
||||
func run() int {
|
||||
var (
|
||||
addr = flag.String("addr", ":8787", "HTTP listen address")
|
||||
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
|
||||
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
|
||||
)
|
||||
@@ -88,12 +89,35 @@ func run() int {
|
||||
}
|
||||
defer mgr.Close()
|
||||
|
||||
skillDir := config.SkillDir()
|
||||
skillDir, err := config.SkillDir()
|
||||
if err != nil {
|
||||
log.Printf("[config] %v", err)
|
||||
return 1
|
||||
}
|
||||
if abs, err := filepath.Abs(skillDir); err == nil {
|
||||
skillDir = abs
|
||||
}
|
||||
log.Printf("[config] skill directory: %s", skillDir)
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir())
|
||||
// JWT signing key directory: ARTEX_JWT_KEY_DIR (the Docker image points it at
|
||||
// the /app/keys volume) or the base dir. Never the browsable data dir.
|
||||
keyDir := strings.TrimSpace(os.Getenv("ARTEX_JWT_KEY_DIR"))
|
||||
if keyDir == "" {
|
||||
keyDir = config.BaseDir()
|
||||
}
|
||||
if abs, err := filepath.Abs(keyDir); err == nil {
|
||||
keyDir = abs
|
||||
}
|
||||
log.Printf("[config] JWT key directory: %s", keyDir)
|
||||
// Proxies whose X-Forwarded-For the login rate limit reads; empty trusts none.
|
||||
trusted, err := server.ParseTrustedProxies(os.Getenv("ARTEX_TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
log.Printf("[config] ARTEX_TRUSTED_PROXIES: %v", err)
|
||||
return 1
|
||||
}
|
||||
if len(trusted) > 0 {
|
||||
log.Printf("[config] trusted proxies: %v", trusted)
|
||||
}
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub(), server.WithTrustedProxies(trusted))
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv.Handler(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
)
|
||||
|
||||
// The shutdown cause must survive the parent context being cancelled first;
|
||||
// losing it would drop the diagnostic on every graceful stop.
|
||||
func TestShutdownContextPreservesNamedCause(t *testing.T) {
|
||||
signalCtx, signalCancel := context.WithCancel(context.Background())
|
||||
ctx, shutdown := shutdownContext(signalCtx)
|
||||
|
||||
+33
-14
@@ -4,7 +4,9 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -96,16 +98,23 @@ func Path() string {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// Load reads and parses the config file. A missing/unreadable file yields a zero
|
||||
// Config (so callers fall back to defaults) rather than an error.
|
||||
func Load() Config {
|
||||
// Load reads and parses the config file. A missing file yields a zero Config (so
|
||||
// callers fall back to defaults). A file that exists but cannot be read or parsed
|
||||
// is an error: ignoring it would run with defaults the operator did not choose.
|
||||
func Load() (Config, error) {
|
||||
var c Config
|
||||
b, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
return c
|
||||
path := Path()
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
return c
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("read config file %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config file %s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SkillDir returns the skill root directory with precedence:
|
||||
@@ -113,17 +122,23 @@ func Load() Config {
|
||||
// env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills
|
||||
//
|
||||
// The directory is created if it does not exist.
|
||||
func SkillDir() string {
|
||||
func SkillDir() (string, error) {
|
||||
var d string
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
|
||||
d = v
|
||||
} else if v := strings.TrimSpace(Load().SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if v := strings.TrimSpace(c.SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
}
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// PostgresDSN resolves the connection string with precedence:
|
||||
@@ -138,7 +153,11 @@ func PostgresDSN() (dsn, source string, err error) {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
||||
return v, "environment variable ARTEX_PG_DSN", nil
|
||||
}
|
||||
db := Load().Database
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
db := c.Database
|
||||
if d := strings.TrimSpace(db.DSN); d != "" {
|
||||
return d, "config file " + Path() + " (database.dsn)", nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -40,3 +41,24 @@ func TestPostgresDSNPrecedence(t *testing.T) {
|
||||
t.Fatalf("file dsn verbatim, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A config file that exists but is not valid JSON must fail loudly, not run with
|
||||
// defaults (or report "no database config") as if the file were absent.
|
||||
func TestMalformedConfigIsAnError(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// What install.sh used to write for a password containing a double quote.
|
||||
os.WriteFile(cfgPath, []byte(`{"database":{"host":"h","password":"a"b","dbname":"d"},"skill_dir":"/s"}`), 0o644)
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
t.Setenv("ARTEX_SKILL_DIR", "")
|
||||
|
||||
if _, _, err := PostgresDSN(); err == nil || !strings.Contains(err.Error(), "parse config file") {
|
||||
t.Fatalf("PostgresDSN: want a parse error, got %v", err)
|
||||
}
|
||||
if d, err := SkillDir(); err == nil {
|
||||
t.Fatalf("SkillDir: want a parse error, got dir %q", d)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: want a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
+20
-14
@@ -64,6 +64,10 @@ type LLMProfile struct {
|
||||
// or sticky routing off a session-id header. "" = not sent. Maps to
|
||||
// agent.Config.SessionHeaderKey.
|
||||
SessionHeaderKey string `json:"session_header_key"`
|
||||
// UserAgent, when non-empty, overrides the User-Agent header on every outbound
|
||||
// request built from this profile. "" = don't override (keep the default UA);
|
||||
// old profiles behave exactly as before. Maps to agent.Config.UserAgent.
|
||||
UserAgent string `json:"user_agent"`
|
||||
// Cost ledger: EUR per 1M tokens (0 = unset; cost is computed only when a
|
||||
// price is set). Set through the same profile API/SaveProfile path.
|
||||
PriceInputPerM float64 `json:"price_input_per_m,omitempty"`
|
||||
@@ -89,8 +93,8 @@ type RetryOverride struct {
|
||||
// profileCols is the read column list (hint variant, no api key) shared by the
|
||||
// list query; profileColsKey is the same with api_key for the single-row loads.
|
||||
const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
|
||||
|
||||
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
||||
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
||||
@@ -101,7 +105,8 @@ func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile
|
||||
&p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS,
|
||||
&p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS,
|
||||
&p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS,
|
||||
&p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM)
|
||||
&p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM,
|
||||
&p.UserAgent)
|
||||
}
|
||||
|
||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||
@@ -143,13 +148,14 @@ func (d *DB) ProfileByID(id int64) (*LLMProfile, error) {
|
||||
}
|
||||
|
||||
// PoolProfiles returns the failover chain in run order, api keys included: the
|
||||
// active profile first, then every other keyed profile that isn't excluded, by
|
||||
// active profile first, then every other usable profile that isn't excluded, by
|
||||
// priority DESC (id ASC to stay stable). Profiles without an api key can't serve
|
||||
// a request, so they never enter the chain. The ordering IS the policy — callers
|
||||
// walk the slice front to back.
|
||||
// a request — except Ollama ('ollama'), which is a keyless local server — so
|
||||
// they never enter the chain. The ordering IS the policy — callers walk the
|
||||
// slice front to back.
|
||||
func (d *DB) PoolProfiles() ([]*LLMProfile, error) {
|
||||
rows, err := d.Query(`SELECT ` + profileColsKey + ` FROM llm_profiles
|
||||
WHERE COALESCE(api_key,'') <> '' AND (is_default OR NOT pool_exclude)
|
||||
WHERE (COALESCE(api_key,'') <> '' OR format='ollama') AND (is_default OR NOT pool_exclude)
|
||||
ORDER BY is_default DESC, priority DESC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -179,27 +185,27 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
r := p.Retry.Clamped()
|
||||
if p.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING id`,
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms,user_agent)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS).Scan(&id)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent).Scan(&id)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(id, p)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22 WHERE id=$23`,
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22,user_agent=$23 WHERE id=$24`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(p.ID, p)
|
||||
}
|
||||
return p.ID, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24 WHERE id=$25`,
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24,user_agent=$25 WHERE id=$26`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(p.ID, p)
|
||||
}
|
||||
|
||||
+9
-4
@@ -363,7 +363,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses','ollama')),
|
||||
base_url TEXT,
|
||||
proxy TEXT,
|
||||
model TEXT NOT NULL,
|
||||
@@ -396,6 +396,8 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
-- Custom session header: when non-empty, every request carries an HTTP header of this name whose value is the current session id
|
||||
-- (chat session/worker intent). For gateways that do prompt caching/sticky routing by a session-id header. ''=do not send.
|
||||
session_header_key TEXT NOT NULL DEFAULT '',
|
||||
-- Custom User-Agent: when non-empty, every outbound LLM request overrides the User-Agent header with this value. ''=do not override (keep the default UA).
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
-- Retry overrides: attempts 0=use global default / -1=disable / >0=use this value; interval 0=use default exponential backoff / >0=fixed milliseconds.
|
||||
-- The three groups correspond to connect retry, empty-response retry, and same-provider safe-window retry; see the ALTER comment below.
|
||||
retry_connect_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -416,11 +418,12 @@ ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Streaming toggle; backfills old databases. Default true = keep existing streaming behavior; old configs upgrade transparently.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true;
|
||||
-- Relax the format constraint to allow openai-responses (OpenAI Responses API); backfills old databases.
|
||||
-- Runs at every startup, idempotent: drop the old CHECK first, then create the new three-value CHECK.
|
||||
-- Relax the format constraint to allow openai-responses (OpenAI Responses API) and
|
||||
-- ollama (local Ollama server, keyless OpenAI-compatible endpoint); backfills old databases.
|
||||
-- Runs at every startup, idempotent: drop the old CHECK first, then create the new four-value CHECK.
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||||
CHECK (format IN ('openai','anthropic','openai-responses','ollama'));
|
||||
|
||||
-- Output cap and its field name; backfills old databases. Defaults 0 / '' = send no cap and keep the max_tokens field name;
|
||||
-- old configs behave exactly as before.
|
||||
@@ -435,6 +438,8 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||||
CHECK (max_tokens >= 0);
|
||||
-- Custom session header name; backfills old databases. Default '' = do not send; old configs behave unchanged.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
|
||||
-- Custom User-Agent; backfills old databases. Default '' = do not override (keep the default UA); old configs behave unchanged.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Per-profile retry overrides (see the LLM retry design doc). Each of the three groups is a pair of
|
||||
-- "attempts + fixed interval" with unified semantics: attempts 0=use the global default, -1=disable this layer's
|
||||
|
||||
+15
-3
@@ -15,7 +15,14 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
artex:
|
||||
image: autumn27/artex:${ARTEX_TAG:-latest}
|
||||
# Built from this checkout. The previous reference was a third-party
|
||||
# namespace whose repository is gone: whoever controls that name could
|
||||
# publish the tag this file would pull.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
ARTEX_VERSION: ${ARTEX_VERSION:-dev}
|
||||
image: artex:local
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -27,13 +34,18 @@ services:
|
||||
ARTEX_LLM_MODEL: ${ARTEX_LLM_MODEL:-}
|
||||
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ARTEX_JWT_KEY_DIR: /app/keys
|
||||
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
|
||||
ports:
|
||||
- "8787:8787"
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
volumes:
|
||||
# Bind the data directory (jwt.key, SQLite, etc.) to data/ in the project root,
|
||||
# Bind the data directory (SQLite, task files, etc.) to data/ in the project root,
|
||||
# so it is easy to inspect/back up on the host and survives container rebuilds.
|
||||
- ./data:/app/data
|
||||
# JWT signing key (jwt.key), kept out of data/, which the file manager browses.
|
||||
# Persisting it keeps users signed in across container rebuilds.
|
||||
- ./keys:/app/keys
|
||||
# Mount skills/ from the project root into the container (overriding the image's built-in /app/skills);
|
||||
# skill changes require no image rebuild — the container reads the host copy.
|
||||
- ./skills:/app/skills
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Autumn-27/artex
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
github.com/Autumn-27/norma v0.4.3
|
||||
@@ -11,9 +11,9 @@ require (
|
||||
github.com/lqqyt2423/go-mitmproxy v1.9.2
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/projectdiscovery/dnsx v1.2.3
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/net v0.62.0
|
||||
golang.org/x/text v0.43.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
)
|
||||
|
||||
@@ -51,10 +51,10 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/tools v0.51.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -106,23 +106,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
+21
-11
@@ -9,6 +9,16 @@ warn(){ printf '\033[33m[!]\033[0m %s\n' "$*"; }
|
||||
die(){ printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
ask(){ local p="$1" d="${2:-}" a; read -rp "$p${d:+ [$d]}: " a; echo "${a:-$d}"; }
|
||||
rand(){ head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24; }
|
||||
# json_str prints $1 as a JSON string literal: backslash, double quote and the
|
||||
# control characters read -r can return are escaped.
|
||||
json_str(){
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"; s="${s//$'\r'/\\r}"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
# sed_repl escapes $1 for the replacement side of s|...|...|: \, | and &.
|
||||
sed_repl(){ printf '%s' "$1" | sed 's/[\\|&]/\\&/g'; }
|
||||
|
||||
# ── docker environment check / auto-install ───────────────────
|
||||
ensure_docker(){
|
||||
@@ -38,16 +48,15 @@ install_docker(){
|
||||
local pw key
|
||||
pw="$(ask 'Postgres password (press Enter to generate randomly)' "$(rand)")"
|
||||
key="$(ask 'ANTHROPIC_API_KEY (may be empty, configure later in the UI)' '')"
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${pw}|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${key}|" .env
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(sed_repl "$pw")|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_repl "$key")|" .env
|
||||
rm -f .env.bak
|
||||
ok ".env generated (POSTGRES_PASSWORD set)"
|
||||
else
|
||||
info "reusing existing .env"
|
||||
fi
|
||||
info "pulling images and starting..."
|
||||
docker compose pull || true
|
||||
docker compose up -d
|
||||
info "building the image from this checkout and starting..."
|
||||
docker compose up -d --build
|
||||
ok "started → http://localhost:8787"
|
||||
info "view logs: docker compose logs -f artex"
|
||||
}
|
||||
@@ -74,16 +83,17 @@ install_local(){
|
||||
DB_SSL="$(ask 'sslmode (disable/require)' disable)" ;;
|
||||
esac
|
||||
|
||||
# generate config.json
|
||||
# generate config.json (string values JSON-escaped: a password may contain " or \)
|
||||
case "$DB_PORT" in (*[!0-9]*|'') die "port must be a number: $DB_PORT" ;; esac
|
||||
cat > config.json <<JSON
|
||||
{
|
||||
"database": {
|
||||
"host": "${DB_HOST}",
|
||||
"host": $(json_str "$DB_HOST"),
|
||||
"port": ${DB_PORT},
|
||||
"user": "${DB_USER}",
|
||||
"password": "${DB_PASS}",
|
||||
"dbname": "${DB_NAME}",
|
||||
"sslmode": "${DB_SSL}"
|
||||
"user": $(json_str "$DB_USER"),
|
||||
"password": $(json_str "$DB_PASS"),
|
||||
"dbname": $(json_str "$DB_NAME"),
|
||||
"sslmode": $(json_str "$DB_SSL")
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
+51
-18
@@ -28,7 +28,7 @@ import (
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const protocolVersion = "2025-06-18"
|
||||
const protocolVersion = "2025-11-25"
|
||||
const legacySSEProtocolVersion = "2024-11-05"
|
||||
|
||||
// ToolName mirrors mcp.ToolName so remote tools share the mcp__server__tool scheme.
|
||||
@@ -66,6 +66,11 @@ type Client struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
sessionID string
|
||||
// initialized is set once initialize succeeds. Until then the
|
||||
// MCP-Protocol-Version header is omitted: the spec requires it on requests
|
||||
// after initialization, and the offered version already travels in the
|
||||
// initialize body.
|
||||
initialized bool
|
||||
|
||||
// legacySSE is the pre-2025 MCP SSE transport used by servers such as GSL5.
|
||||
legacySSE bool
|
||||
@@ -247,18 +252,47 @@ func (c *Client) readLegacySSE(r *bufio.Reader) {
|
||||
}
|
||||
|
||||
func (c *Client) initialize(ctx context.Context) error {
|
||||
version := c.protocol
|
||||
if version == "" {
|
||||
version = protocolVersion
|
||||
versions := []string{protocolVersion, "2025-06-18"}
|
||||
if c.legacySSE {
|
||||
versions = []string{legacySSEProtocolVersion}
|
||||
}
|
||||
if _, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
|
||||
}); err != nil {
|
||||
return err
|
||||
for i, version := range versions {
|
||||
c.mu.Lock()
|
||||
c.protocol = version
|
||||
c.mu.Unlock()
|
||||
result, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
|
||||
})
|
||||
if err != nil {
|
||||
if i+1 < len(versions) && isUnsupportedVersion(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
var negotiated struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
c.mu.Lock()
|
||||
if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" {
|
||||
c.protocol = negotiated.ProtocolVersion
|
||||
}
|
||||
c.initialized = true
|
||||
c.mu.Unlock()
|
||||
return c.notify(ctx, "notifications/initialized", map[string]any{})
|
||||
}
|
||||
return c.notify(ctx, "notifications/initialized", map[string]any{})
|
||||
return fmt.Errorf("mcp: no supported protocol version")
|
||||
}
|
||||
|
||||
// isUnsupportedVersion reports whether err is a server rejecting the offered
|
||||
// protocol version: the spec's "Unsupported protocol version" (JSON-RPC -32602,
|
||||
// or an HTTP 400 on the MCP-Protocol-Version header) or the
|
||||
// UNSUPPORTED_PROTOCOL_VERSION code some servers return instead.
|
||||
func isUnsupportedVersion(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unsupported protocol version") ||
|
||||
strings.Contains(msg, "unsupported_protocol_version")
|
||||
}
|
||||
|
||||
// Tools lists the server's tools and adapts them to CoreTools.
|
||||
@@ -438,17 +472,16 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
version := c.protocol
|
||||
if version == "" {
|
||||
version = protocolVersion
|
||||
c.mu.Lock()
|
||||
sid := c.sessionID
|
||||
initialized, version := c.initialized, c.protocol
|
||||
c.mu.Unlock()
|
||||
if initialized && version != "" {
|
||||
req.Header.Set("MCP-Protocol-Version", version)
|
||||
}
|
||||
req.Header.Set("MCP-Protocol-Version", version)
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
c.mu.Lock()
|
||||
sid := c.sessionID
|
||||
c.mu.Unlock()
|
||||
if sid != "" {
|
||||
req.Header.Set("Mcp-Session-Id", sid)
|
||||
}
|
||||
|
||||
@@ -73,3 +73,163 @@ func TestStreamableClientRequestTimeout(t *testing.T) {
|
||||
}
|
||||
_ = c3.Close()
|
||||
}
|
||||
|
||||
// initRequest captures what the client sent for one MCP request.
|
||||
type initRequest struct {
|
||||
method, header, bodyVersion string
|
||||
}
|
||||
|
||||
func recordRequest(r *http.Request) (rpcRequest, initRequest) {
|
||||
var req rpcRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
rec := initRequest{method: req.Method, header: r.Header.Get("MCP-Protocol-Version")}
|
||||
if p, ok := req.Params.(map[string]any); ok {
|
||||
rec.bodyVersion, _ = p["protocolVersion"].(string)
|
||||
}
|
||||
return req, rec
|
||||
}
|
||||
|
||||
// TestStreamableClientProtocolVersions uses spec-shaped servers that each support
|
||||
// one version: they answer initialize with their own version, accept a request
|
||||
// without MCP-Protocol-Version, and reject any other header value. The client
|
||||
// must send no version header on initialize and the negotiated one afterwards.
|
||||
func TestStreamableClientProtocolVersions(t *testing.T) {
|
||||
for _, supported := range []string{"2025-11-25", "2025-06-18", "2025-03-26"} {
|
||||
t.Run(supported, func(t *testing.T) {
|
||||
var seen []initRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
req, rec := recordRequest(r)
|
||||
seen = append(seen, rec)
|
||||
if rec.header != "" && rec.header != supported {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":%q,"capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID, supported)
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case "tools/list":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[]}}`, req.ID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Tools(context.Background()); err != nil {
|
||||
t.Fatalf("tools/list: %v", err)
|
||||
}
|
||||
if c.protocol != supported {
|
||||
t.Fatalf("negotiated protocol = %q", c.protocol)
|
||||
}
|
||||
var inits int
|
||||
for _, rec := range seen {
|
||||
if rec.method == "initialize" {
|
||||
inits++
|
||||
if rec.header != "" {
|
||||
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
||||
}
|
||||
if rec.bodyVersion != protocolVersion {
|
||||
t.Fatalf("initialize offered %q, want %q", rec.bodyVersion, protocolVersion)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rec.header != supported {
|
||||
t.Fatalf("%s carried MCP-Protocol-Version %q, want %q", rec.method, rec.header, supported)
|
||||
}
|
||||
}
|
||||
if inits != 1 {
|
||||
t.Fatalf("initialize attempts = %d, want 1", inits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableClientVersionRejectionForms keeps the fallback loop honest for
|
||||
// servers that reject the initialize body version instead of negotiating: an
|
||||
// HTTP 400 "Unsupported protocol version", the JSON-RPC -32602 error from the
|
||||
// spec's example, and an unrelated failure that must not trigger a retry.
|
||||
func TestStreamableClientVersionRejectionForms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reject func(w http.ResponseWriter, id int)
|
||||
fallback bool
|
||||
}{
|
||||
{"http 400", func(w http.ResponseWriter, _ int) {
|
||||
http.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest)
|
||||
}, true},
|
||||
{"json-rpc -32602", func(w http.ResponseWriter, id int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32602,"message":"Unsupported protocol version","data":{"supported":["2025-06-18"],"requested":"2025-11-25"}}}`, id)
|
||||
}, true},
|
||||
{"unrelated 401", func(w http.ResponseWriter, _ int) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var inits []initRequest
|
||||
var after []initRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
req, rec := recordRequest(r)
|
||||
if req.Method == "initialize" {
|
||||
inits = append(inits, rec)
|
||||
if rec.bodyVersion != "2025-06-18" {
|
||||
tc.reject(w, req.ID)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
after = append(after, rec)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
||||
for _, rec := range inits {
|
||||
if rec.header != "" {
|
||||
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
||||
}
|
||||
}
|
||||
if !tc.fallback {
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("expected connect error")
|
||||
}
|
||||
if len(inits) != 1 {
|
||||
t.Fatalf("unrelated error retried: %d initialize attempts", len(inits))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if len(inits) != 2 || inits[0].bodyVersion != protocolVersion || inits[1].bodyVersion != "2025-06-18" {
|
||||
t.Fatalf("fallback attempts = %+v", inits)
|
||||
}
|
||||
if c.protocol != "2025-06-18" {
|
||||
t.Fatalf("negotiated protocol = %q", c.protocol)
|
||||
}
|
||||
if len(after) == 0 || after[0].method != "notifications/initialized" || after[0].header != "2025-06-18" {
|
||||
t.Fatalf("requests after initialize = %+v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -14,7 +14,7 @@
|
||||
#
|
||||
# Usage examples:
|
||||
# ./reset-password.sh # local, auto-reads config.json/env, prompts for the new password
|
||||
# ./reset-password.sh -p 'NewPass!' # local, new password given directly
|
||||
# ./reset-password.sh -p 'New-Passphrase!' # local, new password given directly (at least 12 characters)
|
||||
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
|
||||
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
|
||||
# ./reset-password.sh -m docker # docker deployment (reads POSTGRES_* from .env)
|
||||
@@ -95,7 +95,7 @@ PY
|
||||
}
|
||||
|
||||
apply_config_fields() {
|
||||
local line key val
|
||||
local key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
[[ -z "$key" ]] && continue
|
||||
case "$key" in
|
||||
@@ -130,6 +130,10 @@ if [[ -z "$NEWPASS" ]]; then
|
||||
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "the two entries do not match"
|
||||
fi
|
||||
[[ -n "$NEWPASS" ]] || die "password cannot be empty"
|
||||
# Same minimum as the server (minPasswordRunes in server/auth.go), counted in
|
||||
# characters like the server: UTF-8 bytes minus continuation bytes, whatever the locale.
|
||||
NEWPASS_CHARS=$(printf '%s' "$NEWPASS" | LC_ALL=C tr -d '\200-\277' | wc -c)
|
||||
(( NEWPASS_CHARS >= 12 )) || die "password must be at least 12 characters"
|
||||
|
||||
# Hand the password to psql via environment variable (read with \getenv, never in argv/ps)
|
||||
export ARTEX_RESET_NEWPASS="$NEWPASS"
|
||||
|
||||
+3
-1
@@ -31,7 +31,9 @@ while [ $# -gt 0 ]; do
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$KEEP" in (*[!0-9]*|'') echo "--keep must be a positive integer" >&2; exit 2 ;; esac
|
||||
# 0* rejects 0, which would make prune delete the dump this run just wrote, and
|
||||
# leading zeros, which $((KEEP + 1)) would read as octal.
|
||||
case "$KEEP" in (*[!0-9]*|''|0*) echo "--keep must be a positive integer" >&2; exit 2 ;; esac
|
||||
|
||||
# Compose reads .env for credentials; source it for the dump command too.
|
||||
if [ -f .env ]; then
|
||||
|
||||
@@ -58,7 +58,7 @@ Standing rules, enforced by the runner:
|
||||
cd scripts/benchmark
|
||||
|
||||
# against a locally deployed ARTEX, with an independent judge endpoint
|
||||
ARTEX_TOKEN=<jwt or mint via ARTEX_JWT_KEY=/app/jwt.key copy> \
|
||||
ARTEX_TOKEN=<jwt or mint via ARTEX_JWT_KEY=/app/keys/jwt.key copy> \
|
||||
JUDGE_BASE_URL=https://api.example.com JUDGE_MODEL=<other-model> JUDGE_API_KEY=... \
|
||||
python3 run_bench.py --corpus juice-shop --subject-model glm-5.3
|
||||
```
|
||||
|
||||
+148
-21
@@ -2,18 +2,23 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/Autumn-27/artex/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,7 +30,9 @@ const (
|
||||
// The lower bound matches the setup page's frontend validation — validation that
|
||||
// lives only in the frontend is as good as none: hitting the API directly bypasses
|
||||
// it. The upper bound is bcrypt's hard limit: past 72 bytes GenerateFromPassword returns ErrPasswordTooLong; rejecting early beats a cryptic "password encryption failed".
|
||||
minPasswordRunes = 8
|
||||
// Both apply to new passwords only (init, change-password); login never checks
|
||||
// length, so passwords set under the old 8-character minimum still log in.
|
||||
minPasswordRunes = 12
|
||||
maxPasswordBytes = 72
|
||||
)
|
||||
|
||||
@@ -45,28 +52,38 @@ func validatePassword(pw string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// jwtLegacyBaseDir is the second legacy key location (the executable's directory,
|
||||
// where releases before ARTEX_JWT_KEY_DIR wrote the key). A var so tests can point it
|
||||
// at a temp dir.
|
||||
var jwtLegacyBaseDir = config.BaseDir
|
||||
|
||||
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
|
||||
// the project base dir (next to the executable), NOT the browsable workspace root
|
||||
// (dataDir) — the signing key must never be listable/downloadable via the file
|
||||
// manager. Legacy installs kept it at dataDir/jwt.key; if present there and not yet
|
||||
// at the new location, it is migrated (key preserved, so sessions stay valid) and
|
||||
// the old file removed so it disappears from the workspace. On first run a random
|
||||
// key is generated and persisted.
|
||||
// ARTEX_JWT_KEY_DIR (the /app/keys volume in Docker) or the project base dir, NOT
|
||||
// the browsable workspace root (dataDir) — the signing key must never be
|
||||
// listable/downloadable via the file manager. When keyDir/jwt.key is missing, a key
|
||||
// at dataDir/jwt.key, then at BaseDir()/jwt.key, is migrated (key preserved, so
|
||||
// sessions stay valid) and the old file removed. Otherwise a random key is
|
||||
// generated. An existing key file is never overwritten.
|
||||
func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
|
||||
if err := os.MkdirAll(keyDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create jwt key dir: %w", err)
|
||||
}
|
||||
path := filepath.Join(keyDir, jwtKeyFilename)
|
||||
// one-time migration out of the old in-workspace location.
|
||||
if legacy := filepath.Join(dataDir, jwtKeyFilename); legacy != path {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if data, rerr := os.ReadFile(legacy); rerr == nil {
|
||||
if werr := os.WriteFile(path, data, 0o600); werr == nil {
|
||||
_ = os.Remove(legacy)
|
||||
log.Printf("[auth] JWT key migrated from %s to %s (moved out of the browsable workspace)", legacy, path)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := migrateJWTKey(path, dataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) >= 32 {
|
||||
return []byte(strings.TrimSpace(string(data))), nil
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
key := strings.TrimSpace(string(data))
|
||||
if len(key) < 32 {
|
||||
return nil, fmt.Errorf("jwt key %s is shorter than 32 bytes; fix or remove it (it is never overwritten)", path)
|
||||
}
|
||||
return []byte(key), nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read jwt key: %w", err)
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
for i := range buf {
|
||||
@@ -76,13 +93,71 @@ func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
|
||||
}
|
||||
buf[i] = keyChars[n.Int64()]
|
||||
}
|
||||
if err := os.WriteFile(path, buf, 0600); err != nil {
|
||||
if err := writeNewJWTKey(path, buf); err != nil {
|
||||
return nil, fmt.Errorf("write jwt key: %w", err)
|
||||
}
|
||||
log.Printf("[auth] new JWT key written to %s", path)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// migrateJWTKey copies the first legacy key found (dataDir, then BaseDir) to dst
|
||||
// and removes the old file. Candidates equal to dst are skipped.
|
||||
func migrateJWTKey(dst, dataDir string) error {
|
||||
for _, src := range []string{
|
||||
filepath.Join(dataDir, jwtKeyFilename),
|
||||
filepath.Join(jwtLegacyBaseDir(), jwtKeyFilename),
|
||||
} {
|
||||
if samePath(src, dst) {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[auth] cannot read legacy JWT key %s: %v", src, err)
|
||||
continue
|
||||
}
|
||||
if err := writeNewJWTKey(dst, data); err != nil {
|
||||
return fmt.Errorf("migrate jwt key from %s: %w", src, err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
log.Printf("[auth] JWT key copied to %s but the old file %s could not be removed: %v", dst, src, err)
|
||||
}
|
||||
log.Printf("[auth] JWT key migrated from %s to %s", src, dst)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeNewJWTKey creates path with mode 0600, failing if it already exists.
|
||||
func writeNewJWTKey(path string, data []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
if absA, err := filepath.Abs(a); err == nil {
|
||||
a = absA
|
||||
}
|
||||
if absB, err := filepath.Abs(b); err == nil {
|
||||
b = absB
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// signJWT issues a 7-day HS256 token for user ARTEX.
|
||||
func signJWT(key []byte) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
@@ -154,7 +229,34 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
|
||||
}
|
||||
|
||||
// initSetupToken generates the one-time setup token when no admin password is set
|
||||
// and writes it to the server log. Without it, anyone who can reach the port
|
||||
// before the operator could claim the instance through POST /api/auth/init.
|
||||
func (s *Server) initSetupToken() {
|
||||
if s.m == nil || s.m.pg == nil {
|
||||
return
|
||||
}
|
||||
if hash, _, err := s.m.pg.GetSetting(authPassKey); err == nil && hash != "" {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
log.Fatalf("[auth] generate setup token: %v", err)
|
||||
}
|
||||
s.setupToken = hex.EncodeToString(buf)
|
||||
log.Printf("[auth] no admin password is set. Setup token for the /setup page: %s", s.setupToken)
|
||||
}
|
||||
|
||||
// checkSetupToken compares got with the active setup token in constant time.
|
||||
// It fails when no token is active.
|
||||
func (s *Server) checkSetupToken(got string) bool {
|
||||
s.setupMu.Lock()
|
||||
defer s.setupMu.Unlock()
|
||||
return s.setupToken != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.setupToken)) == 1
|
||||
}
|
||||
|
||||
// POST /api/auth/init — sets the password for the first time; rejected if already set.
|
||||
// Requires the setup token from the server log.
|
||||
func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
@@ -170,12 +272,18 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Password string `json:"password"`
|
||||
SetupToken string `json:"setup_token"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil || req.Password == "" {
|
||||
writeErr(w, 400, "Password cannot be empty")
|
||||
return
|
||||
}
|
||||
// 403, not 401: the frontend treats any 401 as an expired session and redirects to /login.
|
||||
if !s.checkSetupToken(req.SetupToken) {
|
||||
writeErr(w, 403, "Invalid setup token. The server log prints it at startup while no password is set")
|
||||
return
|
||||
}
|
||||
if msg := validatePassword(req.Password); msg != "" {
|
||||
writeErr(w, 400, msg)
|
||||
return
|
||||
@@ -197,6 +305,9 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 403, "Password is already set")
|
||||
return
|
||||
}
|
||||
s.setupMu.Lock()
|
||||
s.setupToken = ""
|
||||
s.setupMu.Unlock()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "token generation failed")
|
||||
@@ -264,6 +375,21 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
s.login(w, r, pg.GetSetting)
|
||||
}
|
||||
|
||||
// login is authLogin past the data-source check; getSetting is pg.GetSetting,
|
||||
// a parameter so tests can run it without PostgreSQL. Every attempt counts
|
||||
// against the caller's IP (s.loginLimit, keyed by clientIP) and a successful
|
||||
// one clears it. There is no per-account lockout and no password length check here.
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, getSetting func(string) (string, bool, error)) {
|
||||
ip := clientIP(r, s.trustedProxies)
|
||||
if wait := s.loginLimit.allow(ip); wait > 0 {
|
||||
secs := int((wait + time.Second - 1) / time.Second)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeErr(w, 429, fmt.Sprintf("Too many login attempts, try again in %d seconds", secs))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -276,7 +402,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
hash, ok, err := pg.GetSetting(authPassKey)
|
||||
hash, ok, err := getSetting(authPassKey)
|
||||
if err != nil {
|
||||
writeErr(w, 503, errDataSourceUnavailable)
|
||||
return
|
||||
@@ -289,6 +415,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
s.loginLimit.reset(ip)
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "token generation failed")
|
||||
|
||||
+239
-4
@@ -3,10 +3,188 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// jwtKeyDirs returns fresh key, data and legacy base dirs and points
|
||||
// jwtLegacyBaseDir at the latter for the duration of the test. keyDir does not
|
||||
// exist yet, so loadOrCreateJWTKey has to create it.
|
||||
func jwtKeyDirs(t *testing.T) (keyDir, dataDir, baseDir string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
keyDir = filepath.Join(root, "keys")
|
||||
dataDir = filepath.Join(root, "data")
|
||||
baseDir = filepath.Join(root, "base")
|
||||
for _, d := range []string{dataDir, baseDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := jwtLegacyBaseDir
|
||||
jwtLegacyBaseDir = func() string { return baseDir }
|
||||
t.Cleanup(func() { jwtLegacyBaseDir = old })
|
||||
return keyDir, dataDir, baseDir
|
||||
}
|
||||
|
||||
// assertKeyFileMode checks for 0600. Windows has no Unix permission bits (Go
|
||||
// reports 0666 or 0444), so the check is skipped there.
|
||||
func assertKeyFileMode(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Logf("mode check skipped on windows (got %v)", fi.Mode().Perm())
|
||||
return
|
||||
}
|
||||
if got := fi.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("%s mode=%v want 0600", path, got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s should not exist (stat err=%v)", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateJWTKeyMigrates(t *testing.T) {
|
||||
const legacyKey = "legacy-key-0123456789abcdefghijkl"
|
||||
for _, src := range []string{"data", "base"} {
|
||||
t.Run(src, func(t *testing.T) {
|
||||
keyDir, dataDir, baseDir := jwtKeyDirs(t)
|
||||
legacy := filepath.Join(dataDir, jwtKeyFilename)
|
||||
if src == "base" {
|
||||
legacy = filepath.Join(baseDir, jwtKeyFilename)
|
||||
}
|
||||
if err := os.WriteFile(legacy, []byte(legacyKey+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(key) != legacyKey {
|
||||
t.Fatalf("key=%q want the migrated %q", key, legacyKey)
|
||||
}
|
||||
path := filepath.Join(keyDir, jwtKeyFilename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != legacyKey+"\n" {
|
||||
t.Fatalf("migrated file content=%q, want the legacy bytes unchanged", data)
|
||||
}
|
||||
assertKeyFileMode(t, path)
|
||||
assertMissing(t, legacy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateJWTKeyMigratesDataDirFirst(t *testing.T) {
|
||||
keyDir, dataDir, baseDir := jwtKeyDirs(t)
|
||||
dataKey := "data-key-0123456789abcdefghijklmn"
|
||||
baseKey := "base-key-0123456789abcdefghijklmn"
|
||||
if err := os.WriteFile(filepath.Join(dataDir, jwtKeyFilename), []byte(dataKey), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(baseDir, jwtKeyFilename), []byte(baseKey), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(key) != dataKey {
|
||||
t.Fatalf("key=%q want the dataDir key", key)
|
||||
}
|
||||
assertMissing(t, filepath.Join(dataDir, jwtKeyFilename))
|
||||
}
|
||||
|
||||
func TestLoadOrCreateJWTKeyGenerates(t *testing.T) {
|
||||
keyDir, dataDir, _ := jwtKeyDirs(t)
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Fatalf("len(key)=%d want 32", len(key))
|
||||
}
|
||||
path := filepath.Join(keyDir, jwtKeyFilename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) != 32 || string(data) != string(key) {
|
||||
t.Fatalf("file holds %d bytes, want the 32 returned", len(data))
|
||||
}
|
||||
assertKeyFileMode(t, path)
|
||||
assertMissing(t, filepath.Join(dataDir, jwtKeyFilename))
|
||||
|
||||
again, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(again) != string(key) {
|
||||
t.Fatal("second load returned a different key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateJWTKeyNeverOverwrites(t *testing.T) {
|
||||
keyDir, dataDir, baseDir := jwtKeyDirs(t)
|
||||
if err := os.MkdirAll(keyDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(keyDir, jwtKeyFilename)
|
||||
current := "current-key-0123456789abcdefghijk"
|
||||
if err := os.WriteFile(path, []byte(current), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// legacy keys must not replace the current one, and are left alone.
|
||||
legacyData := filepath.Join(dataDir, jwtKeyFilename)
|
||||
legacyBase := filepath.Join(baseDir, jwtKeyFilename)
|
||||
for _, p := range []string{legacyData, legacyBase} {
|
||||
if err := os.WriteFile(p, []byte("legacy-key-0123456789abcdefghijkl"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(key) != current {
|
||||
t.Fatalf("key=%q want the existing %q", key, current)
|
||||
}
|
||||
for _, p := range []string{legacyData, legacyBase} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("legacy key %s should be untouched: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// a short (invalid) key is an error, not a reason to replace it.
|
||||
short := "too-short"
|
||||
if err := os.WriteFile(path, []byte(short), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadOrCreateJWTKey(keyDir, dataDir); err == nil {
|
||||
t.Fatal("want an error for a short key")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != short {
|
||||
t.Fatalf("short key was overwritten with %d bytes", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
// GetSetting distinguishes "key absent" from "read failed" only through its error:
|
||||
// both return an empty value. If a password handler mistakes the error for "no
|
||||
// password set yet", a database blip opens the init door — authInit would let an
|
||||
@@ -62,16 +240,73 @@ func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// While no password is set, authInit must reject a missing or wrong setup token
|
||||
// and accept the one generated at startup, once.
|
||||
func TestAuthInitRequiresSetupToken(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
clearPassword := func() {
|
||||
if _, err := m.pg.Exec(`DELETE FROM settings WHERE key=$1`, authPassKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
clearPassword()
|
||||
defer clearPassword()
|
||||
|
||||
s := &Server{m: m, jwtKey: []byte("0123456789abcdef0123456789abcdef")}
|
||||
s.initSetupToken()
|
||||
setupToken := s.setupToken
|
||||
if setupToken == "" {
|
||||
t.Fatal("no setup token generated while no password is set")
|
||||
}
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"missing": `{"password":"correct horse battery"}`,
|
||||
"empty": `{"password":"correct horse battery","setup_token":""}`,
|
||||
"wrong": `{"password":"correct horse battery","setup_token":"0000"}`,
|
||||
} {
|
||||
if w := post(body); w.Code != 403 || strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("%s setup token: status=%d want 403 without a token; body=%s", name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash != "" {
|
||||
t.Fatalf("a rejected request changed the password state: hash set=%v err=%v", hash != "", err)
|
||||
}
|
||||
|
||||
ok := `{"password":"correct horse battery","setup_token":"` + setupToken + `"}`
|
||||
if w := post(ok); w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("right setup token: status=%d want 200 with a token; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash == "" {
|
||||
t.Fatalf("password not stored: err=%v", err)
|
||||
}
|
||||
|
||||
// The token is spent: even with the password row removed, it is refused.
|
||||
clearPassword()
|
||||
if w := post(ok); w.Code != 403 {
|
||||
t.Fatalf("reused setup token: status=%d want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, pw string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"seven chars", "1234567", true},
|
||||
{"eight chars", "12345678", false},
|
||||
{"eight multibyte chars counted as runes not bytes", strings.Repeat("€", 8), false},
|
||||
{"three chars reach 9 bytes but only 3 runes", strings.Repeat("€", 3), true},
|
||||
{"eight chars, the old minimum", "12345678", true},
|
||||
{"eleven chars", "12345678901", true},
|
||||
{"twelve chars", "123456789012", false},
|
||||
{"twelve multibyte chars counted as runes not bytes", strings.Repeat("€", 12), false},
|
||||
{"four chars reach 12 bytes but only 4 runes", strings.Repeat("€", 4), true},
|
||||
{"72 bytes", strings.Repeat("a", 72), false},
|
||||
{"73 bytes exceeds the bcrypt cap", strings.Repeat("a", 73), true},
|
||||
} {
|
||||
|
||||
@@ -103,7 +103,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set")
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set")
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +617,9 @@ type LLMProfileDTO struct {
|
||||
// Custom session header name: when non-empty, every request carries this HTTP header,
|
||||
// its value = the current session/intent's session id. '' = do not send. For gateways that key prompt caching/sticky routing off a session-id header.
|
||||
SessionHeaderKey string `json:"session_header_key"`
|
||||
// Custom User-Agent: when non-empty, every outbound LLM request overrides its
|
||||
// User-Agent header with this value. '' = do not override (keep the default UA).
|
||||
UserAgent string `json:"user_agent"`
|
||||
// This profile's retry overrides (connect / empty response / same-provider safe
|
||||
// window). Per item attempts: 0=inherit the global policy | -1=disable that retry
|
||||
// layer | >0=count; interval_ms: 0=default exponential backoff | >0=use that fixed millisecond interval. All zero = fully follow the global policy, i.e. the historical behavior.
|
||||
@@ -644,6 +647,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||
MaxTokens: p.MaxTokens,
|
||||
MaxTokensField: p.MaxTokensField,
|
||||
SessionHeaderKey: p.SessionHeaderKey,
|
||||
UserAgent: p.UserAgent,
|
||||
Retry: p.Retry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import "os"
|
||||
|
||||
// secretEnv lists the credentials read from the environment only at startup:
|
||||
// ARTEX_PG_DSN by NewManager, the API keys by agent.FromEnv inside New.
|
||||
var secretEnv = []string{"ARTEX_PG_DSN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"}
|
||||
|
||||
// ScrubSecretEnv removes secretEnv from the process environment once startup has
|
||||
// read it. Child processes (agent shells, custom tools, MCP stdio servers) copy
|
||||
// os.Environ() when they are spawned, so they no longer inherit these values.
|
||||
//
|
||||
// It runs inside New, after both readers and before the MCP discovery goroutine
|
||||
// and the task resume loop: both can spawn children, and resuming a task starts
|
||||
// workers that run shells. Scrubbing in main after New returned left that window
|
||||
// open.
|
||||
func ScrubSecretEnv() {
|
||||
for _, k := range secretEnv {
|
||||
_ = os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Option configures New. Options keep the test call sites unchanged: only the
|
||||
// binary opts into scrubbing, because tests create several servers in one
|
||||
// process and still need ARTEX_PG_DSN on later calls.
|
||||
type Option func(*Server)
|
||||
|
||||
// WithSecretScrub makes New drop the startup secrets after reading them, before
|
||||
// the MCP discovery goroutine and the task resume loop can spawn children.
|
||||
func WithSecretScrub() Option { return func(s *Server) { s.scrubSecrets = true } }
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A child process spawned after startup must not see the startup secrets, while
|
||||
// other variables still pass through.
|
||||
func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh in PATH")
|
||||
}
|
||||
for _, k := range secretEnv {
|
||||
t.Setenv(k, "leak-"+k)
|
||||
}
|
||||
t.Setenv("ARTEX_SCRUB_KEEP", "kept")
|
||||
|
||||
ScrubSecretEnv()
|
||||
|
||||
out, err := exec.Command(sh, "-c", "env").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := "\n" + string(out)
|
||||
for _, k := range secretEnv {
|
||||
if strings.Contains(env, "\n"+k+"=") {
|
||||
t.Errorf("%s still reaches child processes", k)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(env, "\nARTEX_SCRUB_KEEP=kept\n") {
|
||||
t.Errorf("non-secret variable did not reach the child process")
|
||||
}
|
||||
}
|
||||
|
||||
// New must apply the options it receives. An earlier version accepted
|
||||
// ...Option and never called them, so the binary kept the secrets in the
|
||||
// environment while the unit test above still passed.
|
||||
func TestNewAppliesSecretScrubOption(t *testing.T) {
|
||||
// t.Setenv restores both after the test: the scrub unsets them for the rest
|
||||
// of this process, and later tests still need the DSN.
|
||||
if dsn := os.Getenv("ARTEX_PG_DSN"); dsn != "" {
|
||||
t.Setenv("ARTEX_PG_DSN", dsn)
|
||||
}
|
||||
t.Setenv("ANTHROPIC_API_KEY", "scrub-probe")
|
||||
t.Setenv("OPENAI_API_KEY", "scrub-probe")
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("no manager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_ = New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir(), WithSecretScrub())
|
||||
|
||||
if os.Getenv("ANTHROPIC_API_KEY") != "" || os.Getenv("OPENAI_API_KEY") != "" {
|
||||
t.Fatal("New did not apply WithSecretScrub: the secrets are still in the environment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestSameLLMEndpoint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provA, baseA, provB, baseB string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", "https://gw.example", "anthropic", "https://gw.example/", true},
|
||||
{"anthropic", "https://gw.example/v1/messages", "anthropic", "https://gw.example", true},
|
||||
{"", "", "anthropic", "", true}, // empty provider defaults to anthropic
|
||||
{"openai", " https://gw.example/v1/chat/completions ", "openai", "https://gw.example/v1", true},
|
||||
{"anthropic", "https://evil.example", "anthropic", "https://gw.example", false},
|
||||
{"anthropic", "", "anthropic", "https://gw.example", false}, // canonical host != saved host
|
||||
{"openai", "", "anthropic", "", false}, // different canonical hosts
|
||||
{"openai", "https://gw.example/v1", "openai-responses", "https://gw.example/v1", false},
|
||||
} {
|
||||
if got := sameLLMEndpoint(tc.provA, tc.baseA, tc.provB, tc.baseB); got != tc.want {
|
||||
t.Errorf("sameLLMEndpoint(%q,%q,%q,%q)=%v want %v", tc.provA, tc.baseA, tc.provB, tc.baseB, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyRecorder is a fake Anthropic endpoint that records the x-api-key it receives.
|
||||
func keyRecorder(t *testing.T) (*httptest.Server, *atomic.Int32, *atomic.Value) {
|
||||
var hits atomic.Int32
|
||||
var key atomic.Value
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
key.Store(r.Header.Get("x-api-key"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"data":[]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits, &key
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.HandlerFunc, path string, body map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// The global key must not follow a base_url typed into the test form.
|
||||
func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
|
||||
saved, savedHits, savedKey := keyRecorder(t)
|
||||
other, otherHits, _ := keyRecorder(t)
|
||||
s := &Server{}
|
||||
s.llmCfg = agent.ConfigFrom("anthropic", "m", saved.URL, "global-secret", "")
|
||||
|
||||
resp := postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "streaming": false,
|
||||
})
|
||||
if otherHits.Load() != 0 || resp["ok"] != false {
|
||||
t.Fatalf("global key went to a form-supplied host: hits=%d resp=%v", otherHits.Load(), resp)
|
||||
}
|
||||
|
||||
postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": saved.URL + "/", "api_key": "", "streaming": false,
|
||||
})
|
||||
if k, _ := savedKey.Load().(string); savedHits.Load() != 1 || k != "global-secret" {
|
||||
t.Fatalf("global key not used for its own endpoint: hits=%d key=%q", savedHits.Load(), k)
|
||||
}
|
||||
}
|
||||
|
||||
// The connection test applies the same User-Agent rule as saving a profile: a
|
||||
// value with a control character is a 400 before any request leaves, and a
|
||||
// printable value reaches the wire exactly as given.
|
||||
func TestLLMTestValidatesUserAgent(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var gotUA atomic.Value
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
gotUA.Store(r.Header.Get("User-Agent"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
s := &Server{}
|
||||
post := func(ua string) *httptest.ResponseRecorder {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": fake.URL, "api_key": "k", "streaming": false, "user_agent": ua,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.testLLM(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := post("good/1.0\r\nX-Injected: 1"); rec.Code != http.StatusBadRequest || hits.Load() != 0 {
|
||||
t.Fatalf("control character in user_agent: status %d hits=%d body=%s", rec.Code, hits.Load(), rec.Body.String())
|
||||
}
|
||||
|
||||
const want = "MyTool/1.0 (contact-free)"
|
||||
if rec := post(" " + want + " "); rec.Code != http.StatusOK {
|
||||
t.Fatalf("printable user_agent: status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ua, _ := gotUA.Load().(string); hits.Load() != 1 || ua != want {
|
||||
t.Fatalf("test request User-Agent = %q (hits=%d), want %q", ua, hits.Load(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// A stored profile's key goes only to that profile's base URL, for both the
|
||||
// connection test and the model listing.
|
||||
func TestStoredProfileKeyOnlyForStoredEndpoint(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
s.cfgMu.Lock()
|
||||
s.llmCfg = agent.Config{} // no global key to fall back on
|
||||
s.cfgMu.Unlock()
|
||||
|
||||
saved, savedHits, savedKey := keyRecorder(t)
|
||||
other, otherHits, _ := keyRecorder(t)
|
||||
pid, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: "key-endpoint", Format: "anthropic", Model: "m", BaseURL: saved.URL, APIKey: "profile-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
|
||||
|
||||
for _, ep := range []struct {
|
||||
path string
|
||||
h http.HandlerFunc
|
||||
}{{"/api/llm/test", s.testLLM}, {"/api/llm/models", s.pgListModels}} {
|
||||
resp := postJSON(t, ep.h, ep.path, map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "profile_id": pid, "streaming": false,
|
||||
})
|
||||
if otherHits.Load() != 0 || resp["ok"] != false {
|
||||
t.Fatalf("%s: stored key went to a form-supplied host: hits=%d resp=%v", ep.path, otherHits.Load(), resp)
|
||||
}
|
||||
before := savedHits.Load()
|
||||
postJSON(t, ep.h, ep.path, map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": saved.URL, "api_key": "", "profile_id": pid, "streaming": false,
|
||||
})
|
||||
if k, _ := savedKey.Load().(string); savedHits.Load() == before || k != "profile-secret" {
|
||||
t.Fatalf("%s: stored key not used for its own endpoint: key=%q", ep.path, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// ConfigFrom("ollama", ...) must normalize to the keyless sentinel with our own
|
||||
// defaults, never the SDK's OpenAI-public-endpoint fallback.
|
||||
func TestOllamaConfigFromKeylessDefaults(t *testing.T) {
|
||||
c := agent.ConfigFrom("ollama", "", "", "", "")
|
||||
if c.Format != agent.FormatOllama {
|
||||
t.Fatalf("format=%v, want FormatOllama", c.Format)
|
||||
}
|
||||
if c.Provider() != "ollama" {
|
||||
t.Fatalf("Provider()=%q", c.Provider())
|
||||
}
|
||||
if c.BaseURL != agent.DefaultOllamaBaseURL {
|
||||
t.Fatalf("BaseURL=%q, want default %q", c.BaseURL, agent.DefaultOllamaBaseURL)
|
||||
}
|
||||
if c.Model == "" {
|
||||
t.Fatal("model must default to a non-empty value")
|
||||
}
|
||||
if c.NeedsAPIKey() {
|
||||
t.Fatal("ollama must not require an API key")
|
||||
}
|
||||
// A full endpoint URL is tolerated (the provider appends /chat/completions).
|
||||
e := agent.ConfigFrom("ollama", "llama3.2", "http://box:11434/v1/chat/completions", "", "")
|
||||
if e.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("full endpoint URL not normalized: %q", e.BaseURL)
|
||||
}
|
||||
// A custom key is preserved (e.g. an authenticated Ollama gateway).
|
||||
k := agent.ConfigFrom("ollama", "m", "http://box:11434", "ollama-secret", "")
|
||||
if k.APIKey != "ollama-secret" {
|
||||
t.Fatalf("custom key lost: %q", k.APIKey)
|
||||
}
|
||||
// The natural Ollama URL has no /v1, but the provider appends
|
||||
// /chat/completions, so the base must be normalized to the /v1 root.
|
||||
n := agent.ConfigFrom("ollama", "m", "http://box:11434", "", "")
|
||||
if n.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("bare host URL not normalized: %q", n.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider built from an Ollama config speaks OpenAI Chat Completions on
|
||||
// the wire (the SDK appends /chat/completions) with the placeholder Bearer (no
|
||||
// real key was configured, no OPENAI_API_KEY env leak).
|
||||
func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) {
|
||||
var gotPath, gotAuth, gotModel atomic.Value
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath.Store(r.URL.Path)
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotModel.Store(body.Model)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
cfg := agent.ConfigFrom("ollama", "llama3.2", fake.URL, "", "")
|
||||
cfg.Stream = false // non-streaming: the fake answers one plain JSON body
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req := llm.CompletionRequest{
|
||||
System: []string{"You are a connection test. Output OK."},
|
||||
Messages: []llm.Message{llm.UserText("ping")},
|
||||
}
|
||||
if _, _, _, err = prov.Complete(ctx, req); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
// ConfigFrom normalizes the fake server URL (no /v1) onto the OpenAI root,
|
||||
// so the request must land on /v1/chat/completions. A path of /chat/completions
|
||||
// would mean the base URL leaked through unnormalized (or fell back to OpenAI).
|
||||
if p, _ := gotPath.Load().(string); p != "/v1/chat/completions" {
|
||||
t.Fatalf("path=%q, want /v1/chat/completions", p)
|
||||
}
|
||||
if a, _ := gotAuth.Load().(string); a != "Bearer ollama" {
|
||||
t.Fatalf("Authorization=%q, want placeholder Bearer ollama", a)
|
||||
}
|
||||
if m, _ := gotModel.Load().(string); m != "llama3.2" {
|
||||
t.Fatalf("model on wire=%q", m)
|
||||
}
|
||||
}
|
||||
|
||||
// A keyless Ollama profile must be usable end to end: stored round-trips,
|
||||
// loadProfileConfig accepts it, the task chain validates it, the failover pool
|
||||
// includes it, and the connection test completes against a fake Ollama.
|
||||
func TestOllamaKeylessProfileLifecycle(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
pid, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: "ollama-local", Format: "ollama", Model: "llama3.2",
|
||||
BaseURL: fake.URL, APIKey: "", IsDefault: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
|
||||
|
||||
stored, err := m.pg.ProfileByID(pid)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("ProfileByID: %v %v", stored, err)
|
||||
}
|
||||
|
||||
if _, ok := s.loadProfileConfig(pid); !ok {
|
||||
t.Fatal("keyless ollama profile must load")
|
||||
}
|
||||
if err := s.validateTaskProfileIDs([]int64{pid}); err != nil {
|
||||
t.Fatalf("task chain must accept keyless ollama: %v", err)
|
||||
}
|
||||
pool, err := m.pg.PoolProfiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range pool {
|
||||
if p.ID == pid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("keyless ollama profile must join the failover pool")
|
||||
}
|
||||
|
||||
// Connection test: no api_key in the form either.
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"profile_id": pid, "provider": "ollama", "model": "llama3.2",
|
||||
"base_url": fake.URL, "api_key": "", "streaming": false,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.testLLM(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("handler status %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.OK {
|
||||
t.Fatalf("connection test on keyless ollama failed: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Model listing for an Ollama server works without an API key and tolerates a
|
||||
// base URL with or without the /v1 suffix.
|
||||
func TestOllamaListModels(t *testing.T) {
|
||||
var calls atomic.Value // []string paths
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
paths, _ := calls.Load().([]string)
|
||||
calls.Store(append(paths, r.URL.Path))
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"llama3.2"},{"id":"qwen2.5:14b"}]}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
|
||||
call := func(baseURL string) (bool, []string) {
|
||||
body, _ := json.Marshal(map[string]any{"provider": "ollama", "base_url": baseURL, "api_key": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/models", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.pgListModels(rec, req)
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Models []string `json:"models"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return resp.OK, resp.Models
|
||||
}
|
||||
// Base WITH /v1.
|
||||
ok, models := call(fake.URL + "/v1")
|
||||
if !ok || len(models) != 2 {
|
||||
t.Fatalf("listing with /v1 base failed: ok=%v models=%v", ok, models)
|
||||
}
|
||||
// Base WITHOUT /v1 must still be normalized onto the OpenAI-compatible path
|
||||
// (/v1/models); both calls hit the same endpoint.
|
||||
ok2, models2 := call(fake.URL)
|
||||
if !ok2 || len(models2) != 2 {
|
||||
t.Fatalf("listing without /v1 base failed: ok=%v models=%v", ok2, models2)
|
||||
}
|
||||
if paths, _ := calls.Load().([]string); len(paths) != 2 || paths[0] != "/v1/models" || paths[1] != "/v1/models" {
|
||||
t.Fatalf("both calls must hit /v1/models, got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// The environment path must normalize the base URL too: ARTEX_LLM_BASE_URL is
|
||||
// the natural way to configure a LAN Ollama server, and it used to send chat to
|
||||
// /chat/completions instead of /v1/chat/completions.
|
||||
func TestOllamaFromEnvNormalizesBaseURL(t *testing.T) {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", "ollama")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434")
|
||||
c, ok := agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable")
|
||||
}
|
||||
if c.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("env base URL not normalized: %q", c.BaseURL)
|
||||
}
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434/v1/chat/completions")
|
||||
c, ok = agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable")
|
||||
}
|
||||
if c.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("full endpoint URL not normalized: %q", c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// An Ollama config resolved from the environment must not inherit OPENAI_API_KEY:
|
||||
// Ollama ignores the bearer token, so inheritance would only ship the cloud key
|
||||
// to the local or LAN endpoint.
|
||||
func TestOllamaFromEnvDoesNotInheritOpenAIKey(t *testing.T) {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", "ollama")
|
||||
t.Setenv("OPENAI_API_KEY", "sk-cloud-secret")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "")
|
||||
c, ok := agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable without a key")
|
||||
}
|
||||
if c.APIKey != "" {
|
||||
t.Fatalf("ollama inherited a cloud key: %q", c.APIKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Login attempts are limited per client IP, never per account: a per-account
|
||||
// lockout would let anyone lock the admin out by failing on purpose.
|
||||
const (
|
||||
loginMaxAttempts = 10 // attempts allowed per IP per window
|
||||
loginWindow = time.Minute // the counting window
|
||||
loginBaseBlock = time.Minute // length of the first block
|
||||
loginMaxBlock = 15 * time.Minute // cap on the block; an entry idle this long is dropped
|
||||
loginSweepEvery = time.Minute // how often stale entries are swept
|
||||
loginMaxEntries = 10000 // hard cap on tracked IPs
|
||||
)
|
||||
|
||||
type loginEntry struct {
|
||||
attempts int
|
||||
windowStart time.Time
|
||||
blockedUntil time.Time
|
||||
block time.Duration // current block length; doubles on every new block or blocked attempt
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// loginLimiter counts login attempts per IP. Past loginMaxAttempts in one
|
||||
// loginWindow the IP is blocked for loginBaseBlock; every attempt while blocked,
|
||||
// and every later block, doubles the block up to loginMaxBlock. State is in
|
||||
// memory only and swept of idle entries, so a restart clears it.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
entries map[string]*loginEntry
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(now func() time.Time) *loginLimiter {
|
||||
return &loginLimiter{now: now, entries: map[string]*loginEntry{}}
|
||||
}
|
||||
|
||||
// allow records one attempt from ip. It returns 0 when the attempt may go on,
|
||||
// otherwise how long ip must wait.
|
||||
func (l *loginLimiter) allow(ip string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweep(now)
|
||||
e := l.entries[ip]
|
||||
if e == nil {
|
||||
if len(l.entries) >= loginMaxEntries {
|
||||
l.evictOldest()
|
||||
}
|
||||
e = &loginEntry{windowStart: now}
|
||||
l.entries[ip] = e
|
||||
}
|
||||
e.lastSeen = now
|
||||
if now.Before(e.blockedUntil) {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
return e.block
|
||||
}
|
||||
if now.Sub(e.windowStart) >= loginWindow {
|
||||
e.attempts, e.windowStart = 0, now
|
||||
}
|
||||
if e.attempts >= loginMaxAttempts {
|
||||
if e.block == 0 {
|
||||
e.block = loginBaseBlock
|
||||
} else {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
}
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
e.attempts, e.windowStart = 0, now
|
||||
return e.block
|
||||
}
|
||||
e.attempts++
|
||||
return 0
|
||||
}
|
||||
|
||||
// reset forgets ip, after a successful login. A blocked IP never reaches the
|
||||
// password check, so this only runs outside a block.
|
||||
func (l *loginLimiter) reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
|
||||
// sweep drops entries idle for loginMaxBlock, at most once per loginSweepEvery.
|
||||
// Such an entry is never blocked: a block ends at most loginMaxBlock after the
|
||||
// attempt that set it.
|
||||
func (l *loginLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < loginSweepEvery {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for ip, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) >= loginMaxBlock {
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest drops the least recently seen entry to stay under loginMaxEntries.
|
||||
func (l *loginLimiter) evictOldest() {
|
||||
var oldest string
|
||||
var at time.Time
|
||||
for ip, e := range l.entries {
|
||||
if oldest == "" || e.lastSeen.Before(at) {
|
||||
oldest, at = ip, e.lastSeen
|
||||
}
|
||||
}
|
||||
delete(l.entries, oldest)
|
||||
}
|
||||
|
||||
// peerIP is the host part of the TCP peer address.
|
||||
func peerIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP is the address login attempts are counted against. It is the TCP
|
||||
// peer unless the peer is in trusted: then X-Forwarded-For is read from the
|
||||
// right, past trusted hops, and the first untrusted hop is the client. Entries
|
||||
// left of that hop come from the client itself and are never used, so a client
|
||||
// cannot pick its own bucket. With trusted empty the header is ignored.
|
||||
func clientIP(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer := peerIP(r)
|
||||
addr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(addr, trusted) {
|
||||
return peer
|
||||
}
|
||||
hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
hop, err := netip.ParseAddr(strings.TrimSpace(hops[i]))
|
||||
if err != nil {
|
||||
break // keep the last address a trusted proxy reported
|
||||
}
|
||||
addr = hop
|
||||
if !isTrusted(addr, trusted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return addr.Unmap().WithZone("").String()
|
||||
}
|
||||
|
||||
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
|
||||
a = a.Unmap().WithZone("")
|
||||
for _, p := range trusted {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTrustedProxies parses ARTEX_TRUSTED_PROXIES: comma-separated CIDRs (a
|
||||
// bare address means that one host). Empty means no trusted proxy.
|
||||
func ParseTrustedProxies(s string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, f := range strings.Split(s, ",") {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(f); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
continue
|
||||
}
|
||||
a, err := netip.ParseAddr(f)
|
||||
if err != nil || a.Zone() != "" {
|
||||
return nil, fmt.Errorf("%q is not a CIDR or an IP address", f)
|
||||
}
|
||||
a = a.Unmap()
|
||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithTrustedProxies sets the proxies whose X-Forwarded-For the login rate
|
||||
// limit reads (see clientIP).
|
||||
func WithTrustedProxies(p []netip.Prefix) Option {
|
||||
return func(s *Server) { s.trustedProxies = p }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type fakeClock struct{ t time.Time }
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.t }
|
||||
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||
|
||||
func newFakeClock() *fakeClock { return &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} }
|
||||
|
||||
// oldPassword is 8 characters: valid under the old minimum, too short for a new password now.
|
||||
const oldPassword = "abcd1234"
|
||||
|
||||
// loginServer returns a Server whose login reads a stored hash of oldPassword
|
||||
// from a stub instead of PostgreSQL.
|
||||
func loginServer(t *testing.T, clk *fakeClock) (*Server, func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getSetting := func(key string) (string, bool, error) {
|
||||
if key != authPassKey {
|
||||
return "", false, nil
|
||||
}
|
||||
return string(hash), true, nil
|
||||
}
|
||||
s := &Server{jwtKey: []byte("0123456789abcdef0123456789abcdef"), loginLimit: newLoginLimiter(clk.now)}
|
||||
post := func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
body := fmt.Sprintf(`{"username":"ARTEX","password":%q}`, password)
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
r.RemoteAddr = remote
|
||||
for k, v := range hdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.login(w, r, getSetting)
|
||||
return w
|
||||
}
|
||||
return s, post
|
||||
}
|
||||
|
||||
func failN(t *testing.T, post func(string, string, map[string]string) *httptest.ResponseRecorder, remote string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if w := post(remote, "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("attempt %d from %s: status=%d want 401; body=%s", i+1, remote, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturns429WithRetryAfter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.10:40000"
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
|
||||
w := post(ip, oldPassword, nil) // even the right password is refused while blocked
|
||||
if w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After=%q want 60", got)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error == "" || body.Token != "" {
|
||||
t.Fatalf("want a JSON error body without a token, got %s (err=%v)", w.Body.String(), err)
|
||||
}
|
||||
|
||||
// Each attempt while blocked doubles the wait, capped at loginMaxBlock.
|
||||
for _, want := range []string{"120", "240", "480", "900", "900"} {
|
||||
if got := post(ip, "wrong-password", nil).Header().Get("Retry-After"); got != want {
|
||||
t.Fatalf("Retry-After=%q want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the block runs out the IP gets a fresh window.
|
||||
clk.advance(loginMaxBlock)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("after the block: status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessResetsCounter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.11:40000"
|
||||
failN(t, post, ip, loginMaxAttempts-1)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// The counter starts over: a full budget of failures before the next block.
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
if w := post(ip, "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIsPerIP(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
failN(t, post, "192.0.2.12:40000", loginMaxAttempts)
|
||||
if w := post("192.0.2.12:40001", "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("same IP, other port: status=%d want 429", w.Code)
|
||||
}
|
||||
if w := post("198.51.100.7:40000", "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("other IP: status=%d want 401", w.Code)
|
||||
}
|
||||
if w := post("[2001:db8::1]:40000", oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("other IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIgnoresForwardedFor(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const blocked = "192.0.2.13"
|
||||
failN(t, post, blocked+":40000", loginMaxAttempts)
|
||||
|
||||
// A blocked peer cannot escape by claiming another address.
|
||||
for _, h := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
if w := post(blocked+":40000", oldPassword, map[string]string{h: "203.0.113.9"}); w.Code != 429 {
|
||||
t.Fatalf("%s from blocked peer: status=%d want 429", h, w.Code)
|
||||
}
|
||||
}
|
||||
// And another peer naming the blocked address is not blocked.
|
||||
if w := post("198.51.100.8:40000", oldPassword, map[string]string{"X-Forwarded-For": blocked}); w.Code != 200 {
|
||||
t.Fatalf("X-Forwarded-For naming the blocked IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrusted(t *testing.T, s string) []netip.Prefix {
|
||||
t.Helper()
|
||||
p, err := ParseTrustedProxies(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
if p, err := ParseTrustedProxies(" "); err != nil || len(p) != 0 {
|
||||
t.Fatalf("empty: %v, %v; want no proxies", p, err)
|
||||
}
|
||||
p, err := ParseTrustedProxies(" 127.0.0.1/32, 10.1.2.3/8 ,::1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fmt.Sprint(p); got != "[127.0.0.1/32 10.0.0.0/8 ::1/128]" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
for _, bad := range []string{"proxy.local", "10.0.0.0/33", "127.0.0.1,nope"} {
|
||||
if _, err := ParseTrustedProxies(bad); err == nil {
|
||||
t.Fatalf("%q: want an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
trusted := mustTrusted(t, "127.0.0.1/32,10.0.0.0/8")
|
||||
for _, tc := range []struct {
|
||||
name, remote string
|
||||
xff []string
|
||||
trusted []netip.Prefix
|
||||
want string
|
||||
}{
|
||||
{"direct client, no proxies configured", "198.51.100.9:5000", nil, nil, "198.51.100.9"},
|
||||
{"direct client, proxies configured", "198.51.100.9:5000", nil, trusted, "198.51.100.9"},
|
||||
{"spoofed header, no proxies configured", "198.51.100.9:5000", []string{"203.0.113.1"}, nil, "198.51.100.9"},
|
||||
{"spoofed header from an untrusted peer", "198.51.100.9:5000", []string{"203.0.113.1"}, trusted, "198.51.100.9"},
|
||||
{"spoofed header claiming a trusted address", "198.51.100.9:5000", []string{"127.0.0.1"}, trusted, "198.51.100.9"},
|
||||
{"one trusted proxy", "127.0.0.1:5000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"client-supplied entries left of the real hop", "127.0.0.1:5000", []string{"203.0.113.1, 10.0.0.9, 198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"chain of trusted proxies", "127.0.0.1:5000", []string{"203.0.113.1, 198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"chain split over header lines", "127.0.0.1:5000", []string{"203.0.113.1", "198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"trusted proxy without the header", "127.0.0.1:5000", nil, trusted, "127.0.0.1"},
|
||||
{"unparsable hop stops the walk", "127.0.0.1:5000", []string{"198.51.100.7, garbage, 10.0.0.5"}, trusted, "10.0.0.5"},
|
||||
{"IPv6 client behind the proxy", "127.0.0.1:5000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = tc.remote
|
||||
for _, v := range tc.xff {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
if got := clientIP(r, tc.trusted); got != tc.want {
|
||||
t.Fatalf("clientIP=%s want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a trusted reverse proxy every request arrives from the proxy's address;
|
||||
// the limit must still apply per real client.
|
||||
func TestLoginRateLimitBehindTrustedProxy(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
s, post := loginServer(t, clk)
|
||||
s.trustedProxies = mustTrusted(t, "127.0.0.1/32")
|
||||
const proxy = "127.0.0.1:50000"
|
||||
via := func(xff string) map[string]string { return map[string]string{"X-Forwarded-For": xff} }
|
||||
|
||||
for i := 0; i < loginMaxAttempts; i++ {
|
||||
if w := post(proxy, "wrong-password", via("198.51.100.20")); w.Code != 401 {
|
||||
t.Fatalf("attempt %d: status=%d want 401", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := post(proxy, oldPassword, via("198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker: status=%d want 429", w.Code)
|
||||
}
|
||||
// What nginx's $proxy_add_x_forwarded_for sends when the attacker adds its own header.
|
||||
if w := post(proxy, oldPassword, via("203.0.113.50, 198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker with a forged entry: status=%d want 429", w.Code)
|
||||
}
|
||||
// Another user behind the same proxy is not affected.
|
||||
if w := post(proxy, oldPassword, via("198.51.100.21")); w.Code != 200 {
|
||||
t.Fatalf("other user via the proxy: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The 12-character minimum applies to new passwords only. A hash stored under the
|
||||
// old 8-character minimum must still log in.
|
||||
func TestEightCharPasswordLogsInButIsRejectedAsNew(t *testing.T) {
|
||||
if n := len([]rune(oldPassword)); n != 8 {
|
||||
t.Fatalf("oldPassword has %d characters, want 8", n)
|
||||
}
|
||||
_, post := loginServer(t, newFakeClock())
|
||||
w := post("192.0.2.14:40000", oldPassword, nil)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("login with an existing 8-character password: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// authInit and authChangePassword both refuse a new password through validatePassword.
|
||||
if msg := validatePassword(oldPassword); msg == "" {
|
||||
t.Fatal("validatePassword accepted an 8-character new password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterSweepsIdleEntries(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
l := newLoginLimiter(clk.now)
|
||||
l.allow("192.0.2.20")
|
||||
clk.advance(loginMaxBlock - time.Second)
|
||||
l.allow("192.0.2.21") // sweeps, but .20 is not idle long enough yet
|
||||
if len(l.entries) != 2 {
|
||||
t.Fatalf("entries=%d want 2 before either is idle for %v", len(l.entries), loginMaxBlock)
|
||||
}
|
||||
clk.advance(loginSweepEvery)
|
||||
l.allow("192.0.2.22") // next sweep drops .20
|
||||
if _, ok := l.entries["192.0.2.20"]; ok || len(l.entries) != 2 {
|
||||
t.Fatalf("idle entry not swept: %d entries", len(l.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterCapsEntries(t *testing.T) {
|
||||
l := newLoginLimiter(newFakeClock().now)
|
||||
for i := 0; i <= loginMaxEntries; i++ {
|
||||
l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
|
||||
}
|
||||
if len(l.entries) != loginMaxEntries {
|
||||
t.Fatalf("entries=%d want the cap %d", len(l.entries), loginMaxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHidesVersionWithoutToken(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
s := &Server{jwtKey: key}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.health(w, httptest.NewRequest("GET", "/api/health", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != `{"ok":true}` {
|
||||
t.Fatalf("no token: status=%d body=%s want {\"ok\":true}", w.Code, got)
|
||||
}
|
||||
|
||||
tok, err := signJWT(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/api/health", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
w = httptest.NewRecorder()
|
||||
s.health(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["version"] != BuildVersion || body["service"] != "artex" {
|
||||
t.Fatalf("valid token: body=%s want service and version", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -293,7 +293,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
var pin *int64
|
||||
if id := parseProfileID(a.LLMProfileID); id > 0 {
|
||||
if _, ok := s.loadProfileConfig(id); !ok {
|
||||
return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or has no API key set", id)), nil
|
||||
return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id)), nil
|
||||
}
|
||||
pin = &id
|
||||
} else if a.ParentRef != "" {
|
||||
|
||||
+113
-32
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,7 +45,15 @@ type Server struct {
|
||||
ctx context.Context
|
||||
|
||||
skillDir string // root directory for skill subdirectories
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into keyDir/jwt.key
|
||||
|
||||
scrubSecrets bool // drop ARTEX_PG_DSN and the API keys from the environment once read (binary only)
|
||||
|
||||
setupMu sync.Mutex
|
||||
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
|
||||
|
||||
loginLimit *loginLimiter // per-IP attempt limit on POST /api/auth/login
|
||||
trustedProxies []netip.Prefix // ARTEX_TRUSTED_PROXIES; see clientIP
|
||||
|
||||
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
|
||||
// scheduler tick and an HTTP settings change / task creation can't both count
|
||||
@@ -140,7 +149,7 @@ type triggeredRun struct {
|
||||
mergeable bool // true for finding/goal event triggers (merge by taskID)
|
||||
}
|
||||
|
||||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string) *Server {
|
||||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string, opts ...Option) *Server {
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("[auth] JWT key: %v", err)
|
||||
@@ -150,7 +159,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1),
|
||||
loginLimit: newLoginLimiter(time.Now)}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
s.initSetupToken()
|
||||
s.initSideQuestions()
|
||||
// Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup,
|
||||
// and again on every policy save (saveLLMRetryPolicy).
|
||||
@@ -232,9 +246,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
// Vulnerability IM push delivery engine. Sits beside the Scheduler but independent: push latency demands (3s)
|
||||
// differ from the triggers' business cadence, and neither's failures drag the other down — a stuck push must not affect agent triggers.
|
||||
go newNotifier(s).Run(s.ctx)
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
logSink.SetDB(ctx, m.pg) // restore last 100 log rows and enable async persistence
|
||||
}
|
||||
// precedence: persisted DB config > env.
|
||||
@@ -253,6 +264,16 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
} else {
|
||||
log.Printf("[engine] no LLM provider configured — engine idle until set via /api/llm or env")
|
||||
}
|
||||
// Both env readers have now run (NewManager for the DSN, FromEnv above for the
|
||||
// keys). Drop the secrets before anything below can spawn a child process:
|
||||
// restoring runtimes resumes workers that run shells, and MCP discovery starts
|
||||
// stdio servers.
|
||||
if s.scrubSecrets {
|
||||
ScrubSecretEnv()
|
||||
}
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
s.restoreTaskRuntimes()
|
||||
go s.reconcileConcurrency()
|
||||
s.startTaskArchiveWorker()
|
||||
@@ -334,8 +355,10 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
cfg.Stream = p.Streaming
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
cfg.UserAgent = p.UserAgent
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
if cfg.APIKey == "" {
|
||||
// Ollama profiles run keyless (local server); the other formats need a key.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
}
|
||||
s.cfgMu.Lock()
|
||||
@@ -359,6 +382,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
var maxTokens int
|
||||
var maxTokensField string
|
||||
var sessionHeaderKey string
|
||||
var userAgent string
|
||||
var retry db.RetryOverride
|
||||
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
|
||||
for _, p := range profs {
|
||||
@@ -366,6 +390,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
|
||||
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
sessionHeaderKey = p.SessionHeaderKey
|
||||
userAgent = p.UserAgent
|
||||
retry = p.Retry
|
||||
break
|
||||
}
|
||||
@@ -376,7 +401,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||||
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
|
||||
MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey,
|
||||
MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey, UserAgent: userAgent,
|
||||
Retry: retry,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -491,7 +516,8 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
}
|
||||
|
||||
// loadProfileConfig builds an agent.Config from a specific profile id (with its key).
|
||||
// ok=false when the profile is missing or has no api key.
|
||||
// ok=false when the profile is missing or unusable (no api key for the key-requiring
|
||||
// formats).
|
||||
func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
p, err := s.m.pg.ProfileByID(id)
|
||||
if err != nil || p == nil {
|
||||
@@ -505,8 +531,9 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
cfg.Stream = p.Streaming
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
cfg.UserAgent = p.UserAgent
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
}
|
||||
return cfg, true
|
||||
@@ -946,7 +973,14 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// health answers without a token (requireAuth exempts it), but only a caller
|
||||
// with a valid token gets the service name and version; the web UI's top bar
|
||||
// reads the version, and sends its token.
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
if !verifyJWT(extractToken(r), s.jwtKey) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
|
||||
}
|
||||
|
||||
@@ -1343,12 +1377,13 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg.ContextWindowK = k
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
// Keyless formats (Ollama) need no key; the others keep/require one.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
s.cfgMu.Lock()
|
||||
cfg.APIKey = s.llmCfg.APIKey // keep existing key if not re-entered
|
||||
s.cfgMu.Unlock()
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
writeErr(w, 400, "api_key required")
|
||||
return
|
||||
}
|
||||
@@ -1387,6 +1422,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
ProfileID *int64 `json:"profile_id"` // passed when testing a stored profile: if api_key is empty, its stored key is used
|
||||
Streaming *bool `json:"streaming"` // omitted = streaming, the same defaults as when saving a profile
|
||||
SessionHeaderKey string `json:"session_header_key"` // non-empty = the test also sends this custom session header, with a one-off random session id as the value
|
||||
UserAgent string `json:"user_agent"` // non-empty = the test overrides the User-Agent header with this value (mirrors production)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -1441,29 +1477,64 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
// see TestConnection). Endpoints like opencode zen that mandate x-opencode-session return
|
||||
// 400 without it, so the test path must send it too — otherwise "chat works, test 400s".
|
||||
cfg.SessionHeaderKey = req.SessionHeaderKey
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// Same for the session header name: when the form omits it, the stored profile's value is the fallback.
|
||||
if stored != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
if cfg.APIKey == "" {
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
}
|
||||
if cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
// The custom session header name follows that profile regardless of format:
|
||||
// when the form omits it, the stored profile's value is the fallback.
|
||||
if stored != nil && cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
s.cfgMu.Lock()
|
||||
cfg.APIKey = s.llmCfg.APIKey
|
||||
s.cfgMu.Unlock()
|
||||
// The custom User-Agent follows the profile too, so the test request uses the
|
||||
// exact UA a real conversation would; the form value wins, else the stored one.
|
||||
cfg.UserAgent = strings.TrimSpace(req.UserAgent)
|
||||
if stored != nil && cfg.UserAgent == "" {
|
||||
cfg.UserAgent = strings.TrimSpace(stored.UserAgent)
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
|
||||
// Same rule as saving a profile: a value with control characters or over the
|
||||
// length cap never reaches the wire, not even from the test button.
|
||||
ua, err := agent.ValidateUserAgent(cfg.UserAgent)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.UserAgent = ua
|
||||
// Keyless formats (Ollama) skip the key resolution entirely.
|
||||
if cfg.NeedsAPIKey() {
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// A saved key is only sent to the endpoint it was saved for: base_url and
|
||||
// provider come from the form, so without this check the test button sends
|
||||
// the saved key to any host typed into the form.
|
||||
withheld := false
|
||||
if stored != nil && cfg.APIKey == "" {
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, stored.Format, stored.BaseURL) {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
s.cfgMu.Lock()
|
||||
global := s.llmCfg
|
||||
s.cfgMu.Unlock()
|
||||
if global.APIKey != "" {
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, global.Provider(), global.BaseURL) {
|
||||
cfg.APIKey = global.APIKey
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
msg := "no API key provided"
|
||||
if withheld {
|
||||
msg = "no API key provided: the saved key is only sent to the endpoint it was saved for; enter the key to test a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Retry params do [not] go into the connection test: the test has a 30s hard timeout, and stacking the configured retry counts/long intervals on top
|
||||
// would only turn a perfectly usable endpoint into a "timeout failure". The test asks "does this endpoint work at all"; retry pacing
|
||||
// is a runtime concern.
|
||||
@@ -1478,6 +1549,16 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// sameLLMEndpoint reports whether two provider/base URL pairs resolve to the same
|
||||
// endpoint after ConfigFrom's normalization (trimmed, endpoint suffix stripped;
|
||||
// an empty base URL means the provider's canonical host). A saved API key may only
|
||||
// be sent when the request's endpoint matches the one it was saved for.
|
||||
func sameLLMEndpoint(provA, baseA, provB, baseB string) bool {
|
||||
a := agent.ConfigFrom(provA, "", baseA, "", "")
|
||||
b := agent.ConfigFrom(provB, "", baseB, "", "")
|
||||
return a.Format == b.Format && a.BaseURL == b.BaseURL
|
||||
}
|
||||
|
||||
// truncateReply clips a connection-test reply for display. A model told to answer
|
||||
// "OK" can still ramble (or think out loud); the UI only needs enough to show it
|
||||
// really said something. Rune-based so multibyte text never splits mid-character.
|
||||
@@ -1621,7 +1702,7 @@ func (s *Server) validateTaskProfileIDs(ids []int64) error {
|
||||
}
|
||||
seen[id] = true
|
||||
if _, ok := s.loadProfileConfig(id); !ok {
|
||||
return fmt.Errorf("LLM profile #%d not found or has no API key set", id)
|
||||
return fmt.Errorf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+43
-7
@@ -1882,6 +1882,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
|
||||
p.MaxTokensField = ""
|
||||
}
|
||||
// Custom User-Agent: trim and reject control characters / over-long values so a
|
||||
// bad value can never inject a header or bloat every request. "" = no override.
|
||||
ua, err := agent.ValidateUserAgent(p.UserAgent)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
p.UserAgent = ua
|
||||
id, err := pg.SaveProfile(&p)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -2037,12 +2045,13 @@ func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pgListModels fetches available models from the provider's API endpoint.
|
||||
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
|
||||
// Supports OpenAI-format (GET /models), Anthropic-format (GET /v1/models) and
|
||||
// Ollama (keyless OpenAI-compatible GET /v1/models on the local server); for
|
||||
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on
|
||||
// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root.
|
||||
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"` // "openai" | "anthropic"
|
||||
Provider string `json:"provider"` // "openai" | "anthropic" | "ollama"
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy"`
|
||||
@@ -2052,20 +2061,31 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// Resolve API key: form input > profile stored key.
|
||||
// Resolve API key: form input > profile stored key. The stored key is only
|
||||
// sent to the endpoint it was saved for, since base_url comes from the form.
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
withheld := false
|
||||
if apiKey == "" && req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
apiKey = p.APIKey
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, p.Format, p.BaseURL) {
|
||||
apiKey = p.APIKey
|
||||
} else {
|
||||
withheld = p.APIKey != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
// Ollama serves its model list keyless on the local server: no key gate.
|
||||
if apiKey == "" && provider != "ollama" {
|
||||
msg := "no API Key provided"
|
||||
if withheld {
|
||||
msg = "no API Key provided: the saved key is only sent to the endpoint it was saved for; enter the key to list models from a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
|
||||
// Candidate endpoints to try in order. Some Anthropic-compatible providers
|
||||
// (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose
|
||||
@@ -2097,6 +2117,22 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
|
||||
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
|
||||
case "ollama":
|
||||
// OpenAI-compatible list at {base}/v1/models. Tolerate base URLs with or
|
||||
// without the /v1 suffix (and a full endpoint URL). No auth needed; when a
|
||||
// key was provided anyway, send it as Bearer (Ollama ignores it).
|
||||
if baseURL == "" {
|
||||
baseURL = agent.DefaultOllamaBaseURL
|
||||
}
|
||||
b := strings.TrimSuffix(baseURL, "/chat/completions")
|
||||
if !strings.HasSuffix(b, "/v1") {
|
||||
b += "/v1"
|
||||
}
|
||||
hdr := http.Header{}
|
||||
if apiKey != "" {
|
||||
hdr.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
candidates = append(candidates, candidate{b + "/models", hdr})
|
||||
default: // anthropic
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
|
||||
@@ -41,7 +41,7 @@ type sideQuestionState struct {
|
||||
}
|
||||
|
||||
func sideModel(cfg agent.Config, id int64, name string) sidequestion.Model {
|
||||
b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey})
|
||||
b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey, cfg.UserAgent})
|
||||
h := sha256.Sum256(b)
|
||||
return sidequestion.Model{ProfileID: id, Name: name, Format: string(cfg.Format), Model: cfg.Model, Identity: hex.EncodeToString(h[:]), Streaming: cfg.Stream, WindowTokens: cfg.CompactionWindow()}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR
|
||||
Model: p.Model,
|
||||
Source: source,
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
// Ollama profiles are keyless by design; the other formats need a key.
|
||||
if p.APIKey == "" && p.Format != "ollama" {
|
||||
result.Reason = "LLM profile has no API Key set"
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# ARTEX update script: (1) Docker update (pull new image and rebuild) (2) local build update (rebuild the binary)
|
||||
# ARTEX update script: (1) Docker update (rebuild the image from this checkout) (2) local build update (rebuild the binary)
|
||||
# Counterpart of install.sh: install handles the first-time setup, update upgrades to a new version.
|
||||
# No manual DB migration needed — artex idempotently re-runs schema.sql on every start (with ADD COLUMN/CREATE
|
||||
# INDEX IF NOT EXISTS), so "restart equals migrate". Data (pgdata volume, ./data, ./skills) is not affected.
|
||||
@@ -17,7 +17,16 @@ sync_repo(){
|
||||
[ -d .git ] && command -v git >/dev/null 2>&1 || { warn "not a git working copy, skipping git pull"; return; }
|
||||
[ "$(ask 'Pull the latest code (git pull --ff-only)? (y/n)' y)" = y ] || return
|
||||
if ! git pull --ff-only; then
|
||||
warn "git pull could not fast-forward (local changes or diverged branches) — resolve manually and retry; continuing with the current code"
|
||||
# Continuing would rebuild the OLD tree and still print "Build complete":
|
||||
# exactly the silent stale upgrade this script exists to prevent.
|
||||
cat >&2 <<'RECOVER'
|
||||
[!] git pull could not fast-forward. This happens when the checkout has local
|
||||
changes, or when it was cloned before the history rewrite of 2026-10-23.
|
||||
Refusing to rebuild stale code. Recover, then rerun this script:
|
||||
keep your changes: git stash && git fetch origin && git reset --hard origin/main && git stash pop
|
||||
discard them: git fetch origin && git reset --hard origin/main
|
||||
RECOVER
|
||||
die "aborting: nothing was rebuilt"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -27,23 +36,12 @@ update_docker(){
|
||||
|| die "docker / docker compose not detected; install and deploy with ./install.sh first"
|
||||
[ -f .env ] || die ".env not found; run ./install.sh first to complete the initial deployment"
|
||||
|
||||
# Optional: upgrade to a specific version tag (empty = keep ARTEX_TAG from .env, default latest)
|
||||
local tag; tag="$(ask 'Target image tag (press Enter to keep .env / latest)' '')"
|
||||
if [ -n "$tag" ]; then
|
||||
if grep -q '^ARTEX_TAG=' .env; then
|
||||
sed -i.bak "s|^ARTEX_TAG=.*|ARTEX_TAG=${tag}|" .env && rm -f .env.bak
|
||||
else
|
||||
printf '\nARTEX_TAG=%s\n' "$tag" >> .env
|
||||
fi
|
||||
ok "ARTEX_TAG set to ${tag}"
|
||||
fi
|
||||
|
||||
# Touch only artex: postgres is pinned to 16-alpine and does not need upgrading along
|
||||
# (pulling it wastes bandwidth, and a major version bump risks compatibility). artex
|
||||
# declares depends_on postgres, so an up with the service name starts pg if needed and leaves a running one untouched.
|
||||
info "Pulling new image (artex only)…"
|
||||
docker compose pull artex
|
||||
info "Rebuilding and starting (schema migrates automatically on artex restart)…"
|
||||
info "Building the image from this checkout (artex only)…"
|
||||
docker compose build artex
|
||||
info "Recreating artex (schema migrates automatically on restart)…"
|
||||
docker compose up -d artex
|
||||
ok "Update complete → http://localhost:8787"
|
||||
info "View logs: docker compose logs -f artex"
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [setupToken, setSetupToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
@@ -40,14 +41,14 @@ export default function SetupPage() {
|
||||
setError("The two passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
if (password.length < 12) {
|
||||
setError("Password must be at least 12 characters");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.initPassword(password);
|
||||
const { token } = await api.initPassword(password, setupToken.trim());
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
} catch (err) {
|
||||
@@ -80,7 +81,7 @@ export default function SetupPage() {
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||
{unavailable
|
||||
? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again."
|
||||
: "First time using ARTEX? Set a login password for the account (at least 8 characters)"}
|
||||
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 12 characters)"}
|
||||
</p>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
@@ -92,6 +93,17 @@ export default function SetupPage() {
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-token">Setup token</Label>
|
||||
<Input
|
||||
id="setup-token"
|
||||
value={setupToken}
|
||||
onChange={(e) => setSetupToken(e.target.value)}
|
||||
placeholder="Printed in the server log at startup"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
@@ -99,8 +111,7 @@ export default function SetupPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
autoFocus
|
||||
placeholder="At least 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,7 +127,11 @@ export default function SetupPage() {
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !setupToken.trim() || !password || !confirm}
|
||||
>
|
||||
{loading ? "Saving..." : "Set password and sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -49,13 +49,18 @@ const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "max_tokens (default)" },
|
||||
{ value: "max_completion_tokens", label: "max_completion_tokens" },
|
||||
];
|
||||
// The other two formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out.
|
||||
// The other formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out.
|
||||
const MAX_TOKENS_FIELD_HINTS: Record<string, string> = {
|
||||
openai:
|
||||
"Which key carries the cap. max_tokens is the default and the vast majority of compatible gateways only accept it; OpenAI's official reasoning models (the o series / GPT-5) conversely only accept max_completion_tokens and fail outright with unsupported_parameter when they receive max_tokens.",
|
||||
anthropic: "Only selectable for the openai format. Anthropic's field name is fixed as max_tokens.",
|
||||
"openai-responses": "Only selectable for the openai format. The Responses API's field name is fixed as max_output_tokens.",
|
||||
ollama: "Only selectable for the openai format. Ollama's field name is fixed as max_tokens.",
|
||||
};
|
||||
// LLM profile formats. The default in the create form is "anthropic"; "ollama" is the
|
||||
// keyless local-server format (base URL defaults to http://127.0.0.1:11434/v1).
|
||||
type ProfileFormat = "anthropic" | "openai" | "openai-responses" | "ollama";
|
||||
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1";
|
||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "Not sent (default)" },
|
||||
{ value: "low", label: "low" },
|
||||
@@ -72,10 +77,11 @@ function cooldownText(secs: number) {
|
||||
}
|
||||
|
||||
// The "healthy or not" badge a profile shows on its card. A profile without a Key cannot send any request at all — say that before the breaker;
|
||||
// the other states come from the pool's breaker records (with polling off no new records appear, so "healthy" = no known failure).
|
||||
// the exception is Ollama, a keyless local server that IS callable without a key. The other states come from the pool's breaker
|
||||
// records (with polling off no new records appear, so "healthy" = no known failure).
|
||||
type Health = { label: string; cls: string; hint?: string };
|
||||
function healthOf(p: LLMProfile, m?: LLMPoolMember): Health {
|
||||
if (!p.api_key_hint) {
|
||||
if (!p.api_key_hint && p.format !== "ollama") {
|
||||
return {
|
||||
label: "No Key configured",
|
||||
cls: "border-muted-foreground/40 text-muted-foreground",
|
||||
@@ -215,7 +221,8 @@ function PoolSheet({
|
||||
</div>
|
||||
{inChain.length < 2 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 profiles with an API Key set and pool participation are required.
|
||||
Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 usable profiles (API Key set, or Ollama) with pool participation are
|
||||
required.
|
||||
</p>
|
||||
)}
|
||||
{chain.map((m) => {
|
||||
@@ -311,7 +318,7 @@ function ProfileSheet({
|
||||
}) {
|
||||
const isNew = !profile;
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
|
||||
const [format, setFormat] = React.useState<ProfileFormat>("anthropic");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [proxy, setProxy] = React.useState("");
|
||||
@@ -328,6 +335,7 @@ function ProfileSheet({
|
||||
const [maxTokens, setMaxTokens] = React.useState("0"); // per-reply output cap; 0 = not sent
|
||||
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // which field name carries the cap; NONE = max_tokens
|
||||
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // custom session header name; empty = not sent
|
||||
const [userAgent, setUserAgent] = React.useState(""); // custom User-Agent; empty = keep the default UA
|
||||
const [retry, setRetry] = React.useState<LLMRetryOverride>(ZERO_OVERRIDE); // retry override for this profile; all 0 = follow the global
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -340,7 +348,11 @@ function ProfileSheet({
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile?.name ?? "");
|
||||
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
|
||||
setFormat(
|
||||
profile?.format === "openai" || profile?.format === "openai-responses" || profile?.format === "ollama"
|
||||
? profile.format
|
||||
: "anthropic",
|
||||
);
|
||||
setModel(profile?.model ?? "");
|
||||
setBaseUrl(profile?.base_url ?? "");
|
||||
setProxy(profile?.proxy ?? "");
|
||||
@@ -355,6 +367,7 @@ function ProfileSheet({
|
||||
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||
setSessionHeaderKey(profile?.session_header_key ?? "");
|
||||
setUserAgent(profile?.user_agent ?? "");
|
||||
setRetry(profile?.retry ?? ZERO_OVERRIDE);
|
||||
setApiKey("");
|
||||
setKeyHint(profile?.api_key_hint ?? "");
|
||||
@@ -401,6 +414,7 @@ function ProfileSheet({
|
||||
profileId,
|
||||
streaming,
|
||||
sessionHeaderKey.trim(),
|
||||
userAgent.trim(),
|
||||
);
|
||||
// Show the reply too: only when the model demonstrably spoke is this the same as working inside a session.
|
||||
if (r.ok)
|
||||
@@ -444,6 +458,7 @@ function ProfileSheet({
|
||||
// and the backend re-applies the same normalization; this just keeps the UI from sending contradictory values.
|
||||
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||
session_header_key: sessionHeaderKey.trim(),
|
||||
user_agent: userAgent.trim(),
|
||||
retry,
|
||||
});
|
||||
if (isNew) toast.success(`Created: ${name.trim()} (use "Set as active" on its card to enable it)`);
|
||||
@@ -457,6 +472,13 @@ function ProfileSheet({
|
||||
}
|
||||
}
|
||||
|
||||
let keyPlaceholder = "sk-…";
|
||||
if (format === "ollama") {
|
||||
keyPlaceholder = "optional — a local Ollama server runs keyless";
|
||||
} else if (keyHint) {
|
||||
keyPlaceholder = `Set (${keyHint}); leave empty to keep it`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -492,7 +514,7 @@ function ProfileSheet({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Format</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ProfileFormat)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a format" />
|
||||
</SelectTrigger>
|
||||
@@ -500,6 +522,7 @@ function ProfileSheet({
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
|
||||
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (local, no key needed)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -511,7 +534,7 @@ function ProfileSheet({
|
||||
<Input
|
||||
id="p-model"
|
||||
className="font-mono"
|
||||
placeholder="claude-opus-4-8"
|
||||
placeholder={format === "ollama" ? "llama3.2" : "claude-opus-4-8"}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
@@ -557,7 +580,7 @@ function ProfileSheet({
|
||||
<Input
|
||||
id="p-base-url"
|
||||
className="font-mono"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
placeholder={format === "ollama" ? OLLAMA_DEFAULT_BASE_URL : "https://api.openai.com/v1"}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
@@ -595,11 +618,28 @@ function ProfileSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">API Key</Label>
|
||||
<Label htmlFor="p-user-agent">Custom User-Agent (optional)</Label>
|
||||
<Input
|
||||
id="p-user-agent"
|
||||
className="font-mono"
|
||||
placeholder="e.g. my-app/1.0 (empty = default UA)"
|
||||
value={userAgent}
|
||||
onChange={(e) => setUserAgent(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Overrides the <b>User-Agent</b> header on every outbound LLM request. Useful for gateways that route or allow-list by
|
||||
User-Agent. Leave empty to keep the default User-Agent unchanged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">
|
||||
{format === "ollama" ? "API Key (optional for Ollama)" : "API Key"}
|
||||
</Label>
|
||||
<Input
|
||||
id="p-api-key"
|
||||
type="password"
|
||||
placeholder={keyHint ? `Set (${keyHint}); leave empty to keep it` : "sk-…"}
|
||||
placeholder={keyPlaceholder}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
|
||||
+5
-1
@@ -240,7 +240,8 @@ export const api = {
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
|
||||
initPassword: (password: string, setupToken: string) =>
|
||||
post<{ token: string }>("/auth/init", { password, setup_token: setupToken }),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||
|
||||
@@ -948,6 +949,7 @@ export const api = {
|
||||
profile_id?: number,
|
||||
streaming = true, // test with the profile's real streaming mode so "streaming works, non-streaming does not" never leaks into conversations
|
||||
session_header_key = "", // non-empty = the test request also carries this custom session header (value is a one-off session id)
|
||||
user_agent = "", // non-empty = the test overrides the User-Agent header with this value
|
||||
) =>
|
||||
// reply = the model's actual reply (truncated); the backend marks a profile that replies with nothing as failed
|
||||
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", {
|
||||
@@ -961,6 +963,7 @@ export const api = {
|
||||
profile_id,
|
||||
streaming,
|
||||
session_header_key,
|
||||
user_agent,
|
||||
}),
|
||||
llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)),
|
||||
saveLLMProfile: (p: {
|
||||
@@ -982,6 +985,7 @@ export const api = {
|
||||
max_tokens?: number; // output cap per reply; 0 = not sent, the server default decides
|
||||
max_tokens_field?: string; // ""=max_tokens (default) | "max_completion_tokens" (openai format only)
|
||||
session_header_key?: string; // non-empty = send this HTTP header on every request, value = the current session id; "" = not sent
|
||||
user_agent?: string; // non-empty = override the User-Agent header on every request; "" = keep the default UA
|
||||
retry?: LLMRetryOverride; // this profile's retry override; all zeros = follow the global retry policy
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export interface NotificationDelivery {
|
||||
export interface LLMProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
format: "openai" | "anthropic" | "openai-responses";
|
||||
format: "openai" | "anthropic" | "openai-responses" | "ollama";
|
||||
base_url?: string;
|
||||
proxy?: string;
|
||||
model: string;
|
||||
@@ -1061,6 +1061,8 @@ export interface LLMProfile {
|
||||
// Custom session header name: when non-empty every request carries this HTTP header, value = the current session/intent session id.
|
||||
// "" = not sent. For gateways that do prompt caching/sticky routing on a session-id header.
|
||||
session_header_key?: string;
|
||||
// Custom User-Agent: when non-empty, every outbound LLM request overrides its User-Agent header with this value. "" = do not override (keep the default UA).
|
||||
user_agent?: string;
|
||||
// This profile's retry overrides (connect / empty response / same-provider safety window). Empty / all zeros = follow the global policy.
|
||||
retry?: LLMRetryOverride;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user