mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9481b8e962 | ||
|
|
f90e8d2bcb | ||
|
|
cab0a10c6f | ||
|
|
180538e463 | ||
|
|
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 | ||
|
|
3322ecd6a6 | ||
|
|
7b2a5fb05e | ||
|
|
2d373ae77a | ||
|
|
fc08133ab2 | ||
|
|
334f6fdddc | ||
|
|
a20628d334 | ||
|
|
a6e3fd0a2e | ||
|
|
ddddddd1f3 | ||
|
|
f3f9b8d4fd | ||
|
|
f6583b4bad | ||
|
|
934568f336 |
@@ -5,6 +5,11 @@ web/out
|
||||
server/webui/dist
|
||||
/artex
|
||||
data
|
||||
keys
|
||||
backups
|
||||
.env
|
||||
config.json
|
||||
jwt.key
|
||||
*.db
|
||||
transcripts
|
||||
bench/kv
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# 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
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- run: go build ./...
|
||||
- run: go vet ./...
|
||||
# Skipped tests, tracked in issue #9:
|
||||
# - TestInterceptFilterHTTP, TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# - TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# TestCaptureApprovalLifecycle: fail on unmodified upstream code with a
|
||||
# fresh database.
|
||||
# - TestSideUsageRecordedOnceOnConsumerCancellation: concurrency/cancellation
|
||||
@@ -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/
|
||||
|
||||
+72
-4
@@ -4,18 +4,84 @@ 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` (or `/responses` for the `openai-responses` format), 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 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.
|
||||
- **`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".
|
||||
- **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
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -934,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
|
||||
@@ -98,7 +100,6 @@ The web UI is published on `127.0.0.1` by default. To reach it from another mach
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
# build the single binary first (frontend, then go build -tags embedui), see the Dockerfile header
|
||||
docker compose up -d --build # builds artex from this checkout + starts postgres
|
||||
# → http://localhost:8787
|
||||
```
|
||||
@@ -180,7 +181,7 @@ 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
|
||||
|
||||
@@ -255,13 +256,15 @@ 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 — 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` (or `/responses` for the `openai-responses` format), 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).
|
||||
|
||||
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend+API, `-proxy` traffic recording proxy). The startup script passes arguments through to `artex` verbatim.
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -275,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;
|
||||
@@ -286,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).
|
||||
|
||||
---
|
||||
|
||||
+42
-4
@@ -86,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.
|
||||
@@ -203,7 +207,9 @@ func compactionConfig(windowTokens int) *compaction.Config {
|
||||
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
|
||||
// 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")
|
||||
|
||||
@@ -339,7 +345,7 @@ 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
|
||||
}
|
||||
@@ -438,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) {
|
||||
@@ -450,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
|
||||
@@ -502,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 == "" {
|
||||
@@ -521,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(), server.WithSecretScrub())
|
||||
// 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(),
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -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) {
|
||||
@@ -180,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package db
|
||||
|
||||
// InterceptSource exposes interceptSource to external (db_test) tests.
|
||||
var InterceptSource = interceptSource
|
||||
+19
-3
@@ -37,7 +37,7 @@ type InterceptPending struct {
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Status string `json:"status"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with [Model])
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with ModelReasonPrefix)
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -225,10 +225,26 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
||||
)
|
||||
}
|
||||
|
||||
// ModelReasonPrefix starts every reason written by the LLM fallback judge
|
||||
// (intercept.JudgeReason); readers derive decision_source='model' from it.
|
||||
const ModelReasonPrefix = "[model]"
|
||||
|
||||
// legacyModelReasonPrefix is what the judge wrote before the UI translation (e233c02).
|
||||
const legacyModelReasonPrefix = "[模型]"
|
||||
|
||||
// isModelReason reports whether reason was written by the model judge. The
|
||||
// comparison ignores case: readers used to expect "[Model]" while the writer
|
||||
// produced "[model]", so stored rows may carry either spelling.
|
||||
func isModelReason(reason string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(reason), ModelReasonPrefix) ||
|
||||
strings.HasPrefix(reason, legacyModelReasonPrefix)
|
||||
}
|
||||
|
||||
// Keep legacy rows without decision_source consistent with their displayed source.
|
||||
// Mirrors isModelReason; PostgreSQL LIKE is case-sensitive and '[' is literal.
|
||||
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN ip.reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END)`
|
||||
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
|
||||
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||
@@ -248,7 +264,7 @@ func interceptSource(ruleID int64, reason string) string {
|
||||
if ruleID != 0 {
|
||||
return "rule"
|
||||
}
|
||||
if strings.HasPrefix(reason, "[Model]") {
|
||||
if isModelReason(reason) {
|
||||
return "model"
|
||||
}
|
||||
return "unknown"
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestInterceptApprovalFilters(t *testing.T) {
|
||||
ruleID = rule.ID
|
||||
}
|
||||
if source == "model" {
|
||||
reason = "[Model] fixture"
|
||||
reason = ModelReasonPrefix + " fixture"
|
||||
}
|
||||
id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// The judge writer (package intercept) and the decision_source readers (package
|
||||
// db) must agree on the reason prefix; this pins them together without Postgres.
|
||||
func TestInterceptSourceMatchesJudgeWriter(t *testing.T) {
|
||||
for _, tc := range []struct{ action, message string }{
|
||||
{"allow", ""},
|
||||
{"deny", "destructive command outside the workspace"},
|
||||
{"ask", db.ModelReasonPrefix + " already prefixed"},
|
||||
} {
|
||||
reason := intercept.JudgeReason(tc.action, tc.message)
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("JudgeReason(%q, %q) = %q classified as %q, want model", tc.action, tc.message, reason, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptSourceLegacyModelPrefix(t *testing.T) {
|
||||
for _, reason := range []string{"[模型] 允许", "[Model] fixture"} {
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("%q classified as %q, want model", reason, got)
|
||||
}
|
||||
}
|
||||
if got := db.InterceptSource(0, "legacy reason"); got != "unknown" {
|
||||
t.Fatalf("plain reason classified as %q, want unknown", got)
|
||||
}
|
||||
if got := db.InterceptSource(7, "[model] rule wins"); got != "rule" {
|
||||
t.Fatalf("rule row classified as %q, want rule", got)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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,
|
||||
@@ -436,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
|
||||
@@ -1084,7 +1088,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending (
|
||||
tool_input JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')),
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [Model] prefix) for an LLM fallback decision.
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [model] prefix) for an LLM fallback decision.
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
@@ -1098,6 +1102,12 @@ ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
|
||||
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
|
||||
-- Judge decisions were stored as 'unknown' because the readers expected a capitalized
|
||||
-- prefix while the judge wrote '[model]' (and '[模型]' before e233c02). Idempotent:
|
||||
-- once corrected no row matches the WHERE clause again.
|
||||
UPDATE intercept_pending SET decision_source='model'
|
||||
WHERE decision_source='unknown' AND rule_id IS NULL
|
||||
AND (reason LIKE '[model]%' OR reason LIKE '[模型]%');
|
||||
|
||||
-- =====================================================================
|
||||
-- L. Finding persistence
|
||||
|
||||
@@ -677,7 +677,7 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
||||
source = "unknown"
|
||||
if row["rule_id"] != nil {
|
||||
source = "rule"
|
||||
} else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[Model]") {
|
||||
} else if reason, _ := row["reason"].(string); isModelReason(reason) {
|
||||
source = "model"
|
||||
}
|
||||
row["decision_source"] = source
|
||||
|
||||
+6
-1
@@ -34,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:
|
||||
- "${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=
|
||||
|
||||
+19
-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,17 +48,14 @@ 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 "building the image from this checkout and starting..."
|
||||
if [ ! -f "dist/amd64/artex" ] && [ ! -f "dist/arm64/artex" ]; then
|
||||
warn "no prebuilt binary under dist/ - build it first (frontend, then go build -tags embedui); see the Dockerfile header"
|
||||
fi
|
||||
docker compose up -d --build
|
||||
ok "started → http://localhost:8787"
|
||||
info "view logs: docker compose logs -f artex"
|
||||
@@ -76,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
|
||||
|
||||
+14
-5
@@ -476,11 +476,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
out.ProfileID = cfg.ProfileID
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
out.ConfigDigest = digestInput(configJSON)
|
||||
if out.Message == "" {
|
||||
out.Message = "[model] " + judgeActionLabel(out.Action)
|
||||
} else if !strings.HasPrefix(out.Message, "[model]") {
|
||||
out.Message = "[model] " + out.Message
|
||||
}
|
||||
out.Message = JudgeReason(out.Action, out.Message)
|
||||
if out.Action == "ask" {
|
||||
out.TimeoutEnabled = true
|
||||
out.TimeoutSeconds = cfg.AskTimeoutSeconds
|
||||
@@ -489,6 +485,19 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
return out, true
|
||||
}
|
||||
|
||||
// JudgeReason is the reason stored for a judge verdict: the model's message, or
|
||||
// a label for the action when it gave none, carrying db.ModelReasonPrefix so
|
||||
// the db readers record decision_source='model'.
|
||||
func JudgeReason(action, message string) string {
|
||||
if message == "" {
|
||||
return db.ModelReasonPrefix + " " + judgeActionLabel(action)
|
||||
}
|
||||
if !strings.HasPrefix(message, db.ModelReasonPrefix) {
|
||||
return db.ModelReasonPrefix + " " + message
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func judgeActionLabel(action string) string {
|
||||
switch action {
|
||||
case "allow":
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
+109
-20
@@ -10,12 +10,15 @@ import (
|
||||
"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 (
|
||||
@@ -27,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
|
||||
)
|
||||
|
||||
@@ -47,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 {
|
||||
@@ -78,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{
|
||||
@@ -302,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"`
|
||||
@@ -314,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
|
||||
@@ -327,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")
|
||||
|
||||
+183
-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
|
||||
@@ -124,10 +302,11 @@ func TestValidatePassword(t *testing.T) {
|
||||
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},
|
||||
} {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
)
|
||||
|
||||
func TestInterceptFilterHTTP(t *testing.T) {
|
||||
t.Skip("known failure with a fresh database, pre-existing on unmodified upstream; tracked in BaaRai/ARTEX#9")
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
|
||||
@@ -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,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())
|
||||
}
|
||||
}
|
||||
+69
-9
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -44,13 +45,16 @@ 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
|
||||
// free slots off the same snapshot and over-promote past the limit.
|
||||
@@ -155,7 +159,8 @@ 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)
|
||||
}
|
||||
@@ -350,6 +355,7 @@ 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)
|
||||
// Ollama profiles run keyless (local server); the other formats need a key.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
@@ -376,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 {
|
||||
@@ -383,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
|
||||
}
|
||||
@@ -393,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 {
|
||||
@@ -523,6 +531,7 @@ 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 == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
@@ -964,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})
|
||||
}
|
||||
|
||||
@@ -1406,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())
|
||||
@@ -1465,23 +1482,56 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
if stored != nil && cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// 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 == "" {
|
||||
// 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)
|
||||
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()
|
||||
cfg.APIKey = s.llmCfg.APIKey
|
||||
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 == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1499,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.
|
||||
|
||||
+21
-3
@@ -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())
|
||||
@@ -2053,17 +2061,27 @@ 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 != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
// Ollama serves its model list keyless on the local server: no key gate.
|
||||
if apiKey == "" && provider != "ollama" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ 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);
|
||||
@@ -81,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? Enter the setup token from the server log, then 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 ? (
|
||||
@@ -111,7 +111,7 @@ export default function SetupPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder="At least 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -335,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);
|
||||
@@ -366,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 ?? "");
|
||||
@@ -412,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)
|
||||
@@ -455,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)`);
|
||||
@@ -613,6 +617,21 @@ function ProfileSheet({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<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"}
|
||||
|
||||
@@ -949,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", {
|
||||
@@ -962,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: {
|
||||
@@ -983,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}`),
|
||||
|
||||
@@ -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