Compare commits

..
Author SHA1 Message Date
BaaRai 9481b8e962 fix(intercept): judge decisions were stored as unknown (prefix case mismatch)
Since e233c02 the judge writer prefixes its reason with "[model]" while
interceptSource, the approvalDecisionSource SQL fallback and the archive
restore checked "[Model]", so model-decided rows were inserted with
decision_source='unknown' and missed by the decision_source=model filter.

- db: add ModelReasonPrefix ("[model]") and isModelReason, which also
  accepts the pre-e233c02 "[模型]" prefix; readers compare
  case-insensitively (lower(reason) LIKE in SQL).
- intercept: the writer builds its reason through JudgeReason, using the
  shared constant.
- schema.sql: idempotent backfill of 'unknown' rows without a rule whose
  reason carries either prefix.
- tests: un-skip TestInterceptFilterHTTP (it failed only because of
  this), make the db filter fixture use the constant, and add a
  Postgres-free test pinning the writer output to interceptSource.

Fixes #156
2026-11-09 12:22:46 +01:00
BaaRai f90e8d2bcb Merge pull request #158 from BaaRai/docs/openai-responses-base-url
docs: openai-responses base URL suffix
2026-11-09 12:04:10 +01:00
BaaRai cab0a10c6f docs: openai-responses base URL suffix 2026-11-09 11:53:01 +01:00
BaaRai 180538e463 Merge pull request #157 from BaaRai/docs/anthropic-base-url
docs: Anthropic base URL must not end in /v1
2026-11-09 11:37:27 +01:00
BaaRai a7da0559f3 docs: Anthropic base URL must not end in /v1 2026-11-09 11:27:54 +01:00
BaaRai 9bf5d816ae Merge pull request #155 from BaaRai/feat/llm-user-agent
feat(llm): optional custom User-Agent for LLM requests
2026-11-08 18:26:58 +01:00
BaaRai 4acdf3bb26 fix(llm): validate custom User-Agent in the connection-test path
testLLM set cfg.UserAgent from the request (or the stored-profile
fallback) with no validation, while pgSaveProfile runs
agent.ValidateUserAgent and returns 400 on a bad value. A UA with a
control character could therefore reach the wire from the test button
though it is rejected on save. Call ValidateUserAgent on the final value
(after the stored fallback) and return 400 with the same message style,
mirroring pgSaveProfile; the fallback itself is unchanged.

ValidateUserAgent rejects every control character (r < 0x20 or r ==
0x7f), TAB included, so the header value stays a single printable token.
Extend the agent tests to cover NUL, US, DEL and TAB, and that a
printable slash-token value with spaces survives trimming unchanged. Add
a Postgres-free server test driving testLLM through an httptest endpoint:
a control-character UA is a 400 before any request leaves, a printable UA
reaches the wire verbatim.
2026-11-08 18:15:33 +01:00
BaaRai 13dde2da15 feat(llm): optional custom User-Agent for LLM requests
Add an optional per-profile User-Agent that overrides the header on every
outbound LLM request. When empty (the default), the existing behaviour is
unchanged and the default User-Agent is left untouched.

The value is trimmed and validated: control characters / newlines (header
injection) and values longer than 256 bytes are rejected. Wired through the
profile config struct, the Postgres llm_profiles schema, the profile API/DTO,
the connection-test path, and the LLM profile form, following the existing
session-header field. Addresses part of #154; the fixed-full-URL mode is not
included.
2026-11-08 17:11:43 +01:00
BaaRai a908e1ec17 Merge pull request #153 from BaaRai/chore/dockerignore-and-readme
chore: keep secrets out of the Docker build context; drop stale README step
2026-11-06 14:41:29 +01:00
BaaRai 9784bb192c chore: keep secrets out of the Docker build context; drop stale README step
The build stage of the Dockerfile runs `COPY . .`, so anything left in the
checkout root is sent to the builder and copied into the build stage. Exclude
the local secrets and backups that the install and backup scripts create
there: .env, config.json, jwt.key and backups/. keys/ was already excluded.

The Docker Compose (manual) section of the README still told users to build
the single binary before `docker compose up -d --build`. Since the
multi-stage Dockerfile builds the frontend and the Go binary itself, that
step is no longer needed; remove it.
2026-11-06 14:31:52 +01:00
BaaRai 44d755fb2e Merge pull request #132 from htcom-code/fix/131-mcp-version-fallback
fix(mcp): negotiate protocol version with fallback
2026-11-06 14:31:44 +01:00
BaaRai ce279d77d3 Merge pull request #151 from BaaRai/release/v0.5.1
release: v0.5.1
2026-11-05 18:17:19 +01:00
BaaRai 41e10456d7 release: v0.5.1 2026-11-05 18:09:19 +01:00
BaaRai 43424404ee Merge pull request #150 from BaaRai/fix/login-rate-limit-hardening
fix(auth): rate-limit login per IP, trusted proxies, 12-character passwords
2026-11-05 18:08:11 +01:00
BaaRai cb13a22673 ci: retrigger after PR head sync 2026-11-05 17:30:24 +01:00
BaaRai 8beb9c50d6 fix(ci): drop unused variable in reset-password.sh (SC2034) 2026-11-05 17:11:41 +01:00
BaaRai 4be2fc5c9a docs, ci: proxy setup for host and Docker, shellcheck reset-password.sh
- README: ARTEX_TRUSTED_PROXIES for nginx and ARTEX on the host
  (127.0.0.1) and for nginx on the host with ARTEX in Docker (the bridge
  gateway, found with docker network inspect).
- CI shell job: run shellcheck on reset-password.sh too.
2026-11-05 17:06:10 +01:00
BaaRai d0d153b768 fix(auth): trusted proxies for the login rate limit, 12 characters in reset-password.sh
- ARTEX_TRUSTED_PROXIES (comma-separated CIDRs, empty by default). When
  the TCP peer is listed, the login limit reads X-Forwarded-For from the
  right and keys on the first hop that is not a trusted proxy; otherwise
  the header is ignored. Without it every client behind a reverse proxy
  shares one bucket and an attacker can block the admin. Passed through
  by docker-compose.yml and listed in .env.example.
- README: nginx example sends X-Forwarded-For; document the variable;
  the binary's default listen address is 127.0.0.1:8787.
- reset-password.sh refuses passwords under 12 characters, counted in
  characters like the server.
2026-11-05 16:57:48 +01:00
BaaRai 2419038884 fix(auth): rate-limit login per IP, 12-character passwords, loopback default
- POST /api/auth/login: 10 attempts per minute per TCP peer IP
  (X-Forwarded-For ignored), then 429 with Retry-After. The block starts
  at 1 minute and doubles on each attempt while blocked and on each new
  block, capped at 15 minutes. A successful login clears the IP. No
  per-account lockout. In-memory, idle entries swept after 15 minutes,
  at most 10,000 IPs.
- New passwords (init, change-password, /setup page) need 12 characters.
  Login does not check length, so existing 8-character passwords work.
- The binary's -addr default is 127.0.0.1:8787. The Docker image passes
  -addr :8787 explicitly and is unchanged.
- GET /api/health returns {"ok":true} without a valid token; with one it
  still returns service and version for the web UI top bar.
2026-11-05 16:48:27 +01:00
BaaRai e6e9b2f978 Add Trendshift badge to README
Added a Trendshift badge to the README.
2026-11-04 21:29:45 +01:00
BaaRai 248d9d9673 Merge pull request #149 from BaaRai/fix/jwt-key-volume
fix(auth): keep the JWT signing key in a dedicated volume
2026-11-04 09:57:52 +01:00
htjulia f6583b4bad fix(mcp): omit protocol version header on initialize
The client sent MCP-Protocol-Version on the initialize request itself.
The spec requires the header on requests after initialization, and the
offered version already travels in the initialize body. A server that
validates the header rejected the handshake unless the client happened
to offer a version it supports, so connecting depended on matching the
error body and only worked for the two versions the client offers.

- Send the header only after initialize succeeds, with the version
  the server negotiated
- Keep the fallback loop, newest-first order and negotiated-version
  persistence for servers that still reject the initialize body version
- Rewrite both version tests around this, including a 2025-03-26-only
  server, and assert that initialize carries no version header

Tags: #mcp
Co-Authored-By: htjulia <htjulia1@gmail.com>
2026-11-03 09:24:05 +09:00
htjulia 934568f336 fix(mcp): negotiate protocol version with fallback
The Streamable HTTP client always offered 2025-06-18 and kept sending
that MCP-Protocol-Version header after initialize, ignoring the
version the server answered with. A server that only supports
2025-11-25 negotiates its own version and then rejects every later
request with 400, so none of its tools load. Servers that validate
the header on initialize itself reject the handshake outright, and
the client never retried with another version.

- Offer 2025-11-25 first and fall back to 2025-06-18 when the server
  rejects the version: HTTP 400 "Unsupported protocol version",
  JSON-RPC -32602, or an UNSUPPORTED_PROTOCOL_VERSION code
- Send the protocolVersion from the initialize result on later
  requests
- Keep the legacy HTTP+SSE transport on 2024-11-05

Closes #131

Tags: #mcp
Co-Authored-By: htjulia <htjulia1@gmail.com>
2026-11-02 15:40:45 +09:00
35 changed files with 1138 additions and 68 deletions
+4
View File
@@ -6,6 +6,10 @@ server/webui/dist
/artex
data
keys
backups
.env
config.json
jwt.key
*.db
transcripts
bench/kv
+3
View File
@@ -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
+2 -2
View File
@@ -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
@@ -63,7 +63,7 @@ jobs:
# 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
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
web:
runs-on: ubuntu-latest
+24 -1
View File
@@ -4,6 +4,23 @@ 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 binary listens on `127.0.0.1:8787` by default**: the `-addr` default was `:8787`, every interface. Running `artex` or `./start.sh` without `-addr` now accepts connections from the local machine only; pass `-addr :8787` to listen on every interface. Docker is unchanged: the image starts the server with `-addr :8787` inside the container, and `docker-compose.yml` already publishes it on `127.0.0.1` by default (`ARTEX_BIND_ADDR`).
- **New passwords need at least 12 characters** (was 8), at first-run setup (`POST /api/auth/init` and the `/setup` page) and in `POST /api/auth/change-password`. Login does not check length, so a password set under the old minimum keeps working until it is changed. `reset-password.sh` enforces the same minimum.
### Fixed
- **The JWT signing key survives container rebuilds**: the server wrote `jwt.key` to the executable's directory, `/app` in the image, so the key lived in the container's writable layer and recreating the container (for example `docker compose up -d --build`) generated a new key and signed every user out. The key directory is now set by `ARTEX_JWT_KEY_DIR` (default: the executable's directory, unchanged for non-Docker installs). `docker-compose.yml` sets it to `/app/keys` and bind-mounts `./keys` there, outside `./data`, which the file manager browses. On start, when `<key dir>/jwt.key` is missing, a key at `data/jwt.key`, then at the executable's directory, is copied there with mode 0600 and the old file is removed; otherwise a new key is generated. An existing key is never overwritten. Upgrading a Docker install signs every user out once, because the old key is in the container being replaced; to keep sessions, copy it out before recreating the container:
@@ -13,6 +30,11 @@ All notable changes to this project are documented in this file. The format is b
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.
@@ -978,7 +1000,8 @@ 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.5.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
+13 -2
View File
@@ -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&amp;utm_medium=badge&amp;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
```
@@ -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).
---
+39 -3
View File
@@ -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.
@@ -341,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
}
@@ -440,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) {
@@ -452,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
@@ -504,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 == "" {
@@ -523,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
+3 -3
View File
@@ -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)
}
+91
View File
@@ -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)
}
}
+11 -2
View File
@@ -50,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)")
)
@@ -108,7 +108,16 @@ func run() int {
keyDir = abs
}
log.Printf("[config] JWT key directory: %s", keyDir)
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub())
// 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(),
+15 -10
View File
@@ -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)
}
+4
View File
@@ -0,0 +1,4 @@
package db
// InterceptSource exposes interceptSource to external (db_test) tests.
var InterceptSource = interceptSource
+19 -3
View File
@@ -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"
+1 -1
View File
@@ -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 {
+37
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -35,6 +35,7 @@ services:
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"
+14 -5
View File
@@ -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
View File
@@ -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)
}
+160
View File
@@ -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
View File
@@ -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"
+21 -2
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -29,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
)
@@ -372,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"`
@@ -384,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
@@ -397,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")
+5 -4
View File
@@ -302,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},
} {
+4
View File
@@ -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,
}
}
-1
View File
@@ -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")
+38
View File
@@ -81,6 +81,44 @@ func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
}
}
// 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) {
+191
View File
@@ -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 }
}
+299
View File
@@ -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())
}
}
+33 -2
View File
@@ -9,6 +9,7 @@ import (
"log"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"path/filepath"
@@ -51,6 +52,9 @@ type Server struct {
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,6 +1482,20 @@ 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.
+8
View File
@@ -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())
+1 -1
View File
@@ -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()}
}
+4 -4
View File
@@ -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>
+19
View File
@@ -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"}
+3
View File
@@ -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}`),
+2
View File
@@ -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;
}