mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a9587617 | ||
|
|
668e099dd8 | ||
|
|
04777513b6 | ||
|
|
90e7b3ef55 | ||
|
|
b100848ebf | ||
|
|
0093781c8b | ||
|
|
e94471609f | ||
|
|
77e2c5f9fa | ||
|
|
41fe40af07 | ||
|
|
0d9602dc56 | ||
|
|
5e872ccc15 | ||
|
|
d3c4d21086 | ||
|
|
69831f5cdf | ||
|
|
3322ecd6a6 | ||
|
|
7b2a5fb05e | ||
|
|
2d373ae77a | ||
|
|
fc08133ab2 | ||
|
|
334f6fdddc | ||
|
|
a20628d334 | ||
|
|
a6e3fd0a2e | ||
|
|
f3f9b8d4fd |
@@ -44,6 +44,26 @@ jobs:
|
||||
# as rotating failures in the agent package). Serial package execution
|
||||
# keeps the database state deterministic.
|
||||
run: go test -p 1 ./...
|
||||
- name: go test -race
|
||||
# Race detector on the packages that pass under -race today. ./server is
|
||||
# left out until it has been checked with -race against postgres.
|
||||
# Its own database: the plain run above leaves rows behind and the db tests
|
||||
# are not idempotent across runs (duplicate llm_profiles names).
|
||||
env:
|
||||
ARTEX_PG_DSN: postgres://artex:testpass@localhost:5432/artextest_race?sslmode=disable
|
||||
|
||||
run: >-
|
||||
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+4
-1
@@ -57,7 +57,10 @@ Thumbs.db
|
||||
# bench benchmark related (local artifacts, not committed)
|
||||
/bench/
|
||||
/Dockerfile.bench
|
||||
/Dockerfile.bench.v2backups/
|
||||
/Dockerfile.bench.v2
|
||||
|
||||
# scripts/backup.sh output (database dumps, data archives)
|
||||
backups/
|
||||
|
||||
# posture probe raw output
|
||||
posture-runs/
|
||||
|
||||
+45
-8
@@ -4,18 +4,54 @@ All notable changes to this project are documented in this file. The format is b
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
## [0.5.0] - 2026-11-04
|
||||
|
||||
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally.
|
||||
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat.
|
||||
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
|
||||
|
||||
### Added
|
||||
|
||||
- **Ollama support**: an `ollama` LLM profile format for a local or LAN Ollama server. Keyless by design: no API key is needed, `OPENAI_API_KEY` is never inherited, the base URL defaults to `http://127.0.0.1:11434/v1` and is normalized to the `/v1` root, and the profile works in the failover pool, task LLM chains, model listing and connection tests. Contributed by @glauberportella (#128).
|
||||
- **Setup token for first-run setup**: while no admin password is set, the server generates a random 128-bit one-time token at startup and prints it to its log (`docker compose logs artex | grep "Setup token"`). The `/setup` page has a field for it, `POST /api/auth/init` requires it (`setup_token`, compared in constant time, 403 when missing or wrong), and it is cleared once the password is stored (#137).
|
||||
- **`ARTEX_BIND_ADDR`**: the address `docker-compose.yml` publishes the web UI on, default `127.0.0.1` (#137).
|
||||
|
||||
### Security
|
||||
|
||||
- **First-run claim**: before, anyone who reached port 8787 on a fresh install before the operator could set the admin password. Init now needs the setup token above, and Docker installs publish 8787 on `127.0.0.1` by default; set `ARTEX_BIND_ADDR=0.0.0.0` in `.env`, or use a reverse proxy or SSH tunnel, for remote access (#137).
|
||||
- **Startup secrets removed from the environment**: `ARTEX_PG_DSN`, `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are unset inside `server.New` after they are read and before the MCP discovery goroutine and the task resume loop start, so agent shells, MCP stdio servers and custom tools spawned afterwards no longer inherit them (#136). The binary passes the option that enables this, but `New` did not apply its options, so the scrub from #136 never ran until #146 fixed it (#146). See Known issues for what this does not cover.
|
||||
- **Saved API keys go only to their own endpoint**: `POST /api/llm/test` and `POST /api/llm/models` used a saved key (the profile's, or the global one) for any `base_url` in the request body, so a crafted request could send it to another host. A saved key is now sent only when the request's provider and normalized base URL match the ones it was saved for; otherwise the request must carry `api_key` (#142).
|
||||
- **`ARTEX_LLM_PROVIDER` is matched case-insensitively**: `Ollama` or a value with surrounding spaces fell through to the Anthropic default and sent `ANTHROPIC_API_KEY` to the configured base URL (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat (direct commit 1c35c9c).
|
||||
- **Malformed `config.json` is an error**: `config.Load` used to ignore a parse error and treat the file as missing, which surfaced as a misleading "no database config found" or a silently ignored `skill_dir`. A read or parse error is now returned with the file path and startup exits (#141).
|
||||
- **`install.sh` escapes what it writes**: the local install writes `config.json` string values through a JSON escaper and requires a numeric port; the Docker install escapes `\`, `|` and `&` in the Postgres password and API key before the `.env` substitution (#141).
|
||||
- **`scripts/backup.sh --keep 0` is rejected**: it used to delete every dump, including the one just written, and still report success. Values with a leading zero are rejected too (#139).
|
||||
- **`backups/` is ignored again**: a missing newline had merged it into the previous `.gitignore` line, so database dumps showed up as untracked files (#138).
|
||||
|
||||
### Build and CI
|
||||
|
||||
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally (direct commit 1ed154e).
|
||||
- **A fresh clone builds the image**: the Dockerfile copied a prebuilt `dist/<arch>/artex` that a clean checkout does not have. It is now a multi-stage build: a Node 22 stage runs the frontend static export, a Go 1.26 stage runs `build.sh` with the release binaries job's flags (CGO-free cross-compile, `-trimpath`, version stamp), and the runtime stage copies the result (#147).
|
||||
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete" (direct commit 1ed154e).
|
||||
- **CI runs the race detector and shellcheck**: `go test -race -p 1` over every package with tests except `./server`, and `shellcheck -S warning` on `install.sh`, `update.sh`, `start.sh` and `scripts/backup.sh` (#144). The race run uses its own database (direct commit 0477751).
|
||||
|
||||
### Upgrades
|
||||
|
||||
- **Go 1.26.10** in `go.mod` (was 1.26.3), and **`golang.org/x/net` v0.62.0** (was v0.58.0), which raised x/crypto to 0.58.0, x/text to 0.43.0, x/sys to 0.49.0, x/sync to 0.24.0, x/mod to 0.41.0 and x/tools to 0.51.0 (#145).
|
||||
- **Node 22 in the runtime image**: NodeSource `setup_22.x` instead of the end-of-life `setup_20.x`, matching CI and the release workflow (#143).
|
||||
|
||||
### Known issues
|
||||
|
||||
- **The JWT signing key is not in the Docker volume**: the server writes `jwt.key` to the directory of the executable (`config.BaseDir()`), which is `/app` in the image, while the Dockerfile and `docker-compose.yml` comments say it lives in `/app/data`. The key therefore sits in the container's writable layer: recreating the container (for example `docker compose up -d --build` after an update) generates a new key and signs every user out. A key found at `data/jwt.key` is moved to `/app/jwt.key` on start and removed from the volume.
|
||||
- **The secret scrub has limits**: `os.Unsetenv` changes only the Go runtime's copy, so a process running as the same user can still read `/proc/<pid>/environ` of the server (and, in Docker, of PID 1, `start.sh`). Other variables that may carry credentials, such as `ARTEX_LLM_PROXY`, are not scrubbed.
|
||||
- **The LLM test and model-list endpoints still take `proxy` from the request**, so a saved key can travel through a request-supplied proxy when the base URL is plain HTTP.
|
||||
- **Postgres passwords with URL-special characters** (`@`, `/`, `#`, `?`) break the `ARTEX_PG_DSN` that `docker-compose.yml` builds by interpolation, and Compose interpolates `$` in `.env`.
|
||||
- **The build context includes local secrets**: `.dockerignore` does not exclude `.env`, `config.json` or `jwt.key`, so the build stage's `COPY . .` puts them in a build-stage layer of the local builder cache. They are not copied into the final image.
|
||||
- **Image supply chain**: the recon tools are downloaded at pinned versions but not checksum-verified, sqlmap tracks its default branch, and the Playwright packages are installed at `@latest`. Draft #127 proposes checksum verification.
|
||||
- **No Docker image is published for this release**: the release workflow's `docker` job only runs when `DOCKERHUB_ENABLED` is set, and it still downloads prebuilt binaries into `dist/` that the Dockerfile no longer uses. Build the image from the checkout with `docker compose up -d --build`.
|
||||
- **CI gaps**: `./server` is not run under `-race`; there is no `govulncheck` step and no `docker build` job. Four tests skip themselves (fresh-database or timing failures, see #9).
|
||||
- **Reported and not fixed in this release**: `list_task_credentials` emits an invalid `"required": [""]` schema that strict LLM providers reject (#130), and the MCP Streamable HTTP client fails against servers that only support protocol version 2025-11-25 (#131; fix proposed in #132).
|
||||
|
||||
## [0.4.0] - 2026-10-23
|
||||
|
||||
@@ -934,7 +970,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.4.0...HEAD
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/BaaRai/ARTEX/compare/v0.3.15...v0.4.0
|
||||
[0.3.10]: https://github.com/Autumn-27/ARTEX/compare/v0.3.9...v0.3.10
|
||||
[0.3.9]: https://github.com/Autumn-27/ARTEX/compare/v0.3.8...v0.3.9
|
||||
|
||||
+48
-14
@@ -1,24 +1,58 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Runtime image (no compilation inside the image): install common tools only and ship
|
||||
# a **pre-built Linux single binary**. The binary is cross-compiled by the CI binaries
|
||||
# job (pure Go, no QEMU) and placed at dist/<TARGETARCH>/artex in the build context.
|
||||
# This way multi-arch builds only need to emulate the apt layer for arm64 instead of
|
||||
# emulating the Next/Go compilation, which is much faster.
|
||||
# Multi-stage build: `docker compose up -d --build` (or `docker build .`) produces
|
||||
# the complete image from source. No prebuilt binary in the build context is needed,
|
||||
# so a fresh `git clone` builds — dist/ is git-ignored and is no longer required.
|
||||
#
|
||||
# To build the image manually, prepare the binary yourself first:
|
||||
# cd web && npm run build:static && cd ..
|
||||
# cp -r web/out server/webui/dist
|
||||
# CGO_ENABLED=0 GOARCH=amd64 go build -tags embedui -o dist/amd64/artex ./cmd/artex
|
||||
# docker build -t artex:local .
|
||||
# frontend (web) -> Go binary with embedded UI (build) -> runtime image
|
||||
#
|
||||
# The Go binary is produced by build.sh with the same flags the release workflow's
|
||||
# binaries job uses, so the image binary and the published release binary come from
|
||||
# one source.
|
||||
|
||||
# ---------------------------------------------------------------- frontend ---
|
||||
# Pinned to the Node 22 line the CI/release workflows use. On the build platform
|
||||
# only; the frontend static export is architecture-independent.
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS web
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY web/ ./
|
||||
RUN npm run build:static
|
||||
|
||||
# ------------------------------------------------------------- Go binary ---
|
||||
# Pinned to the Go 1.26 line (matches go.mod and the release binaries job). The
|
||||
# binary is CGO-free, so this stage runs on the build platform and cross-compiles
|
||||
# to $TARGETARCH without QEMU.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
# Reuse the frontend built above; build.sh skips its own frontend step.
|
||||
COPY --from=web /src/web/out ./server/webui/dist
|
||||
ARG ARTEX_VERSION=dev
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
# Same flags as the release binaries job (ARTEX_COMPRESS=0, -trimpath,
|
||||
# -X main.version), driven through build.sh so there is one build path.
|
||||
RUN ARTEX_SKIP_FRONTEND=1 \
|
||||
ARTEX_SKIP_NPM_CI=1 \
|
||||
ARTEX_COMPRESS=0 \
|
||||
ARTEX_OUTPUT=/out/artex \
|
||||
ARTEX_BUILD_VERSION="${ARTEX_VERSION}" \
|
||||
bash ./build.sh --target "${TARGETOS:-linux}/${TARGETARCH:-amd64}"
|
||||
|
||||
# ---------------------------------------------------------------- runtime ---
|
||||
FROM python:3.12-slim-bookworm
|
||||
ARG TARGETARCH
|
||||
# Common tools: ripgrep / curl / vim, plus a set of recon staples (add/remove as needed).
|
||||
# Node 20.x comes from NodeSource: bookworm's apt nodejs is 18, but Playwright requires >=20.
|
||||
# Node 22.x comes from NodeSource: bookworm's apt nodejs is 18, Playwright requires >=20,
|
||||
# and 20 is end-of-life. 22 matches the CI and release workflows.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates ripgrep curl wget vim git jq unzip \
|
||||
dnsutils iputils-ping netcat-openbsd inetutils-telnet whois nmap \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Web-recon toolbox (drost-community take 1, #100): pinned release binaries installed
|
||||
@@ -83,8 +117,8 @@ RUN npm install -g @playwright/mcp@latest @playwright/cli@latest playwright@late
|
||||
&& playwright install --with-deps chromium \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# pre-built binary for the matching architecture (dist/amd64/artex or dist/arm64/artex)
|
||||
COPY dist/${TARGETARCH}/artex /app/artex
|
||||
# Binary compiled from source in the build stage above.
|
||||
COPY --from=build /out/artex /app/artex
|
||||
# Daemon startup script: relaunches the process after a crash and forwards SIGTERM
|
||||
# to artex — docker stop only signals PID 1; without forwarding, artex never receives it,
|
||||
# cannot shut down gracefully, and is hard-killed by SIGKILL after 10 seconds.
|
||||
|
||||
+3
-1
@@ -203,7 +203,9 @@ func compactionConfig(windowTokens int) *compaction.Config {
|
||||
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
|
||||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama)
|
||||
func FromEnv() (Config, bool) {
|
||||
prov := os.Getenv("ARTEX_LLM_PROVIDER")
|
||||
// Case- and space-insensitive: "Ollama" used to miss every case below and
|
||||
// fall through to anthropic, sending ANTHROPIC_API_KEY to the Ollama base URL.
|
||||
prov := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_LLM_PROVIDER")))
|
||||
anthKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
oaiKey := os.Getenv("OPENAI_API_KEY")
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// ARTEX_LLM_PROVIDER is matched regardless of case and surrounding spaces. A
|
||||
// miss used to fall through to anthropic: "Ollama" then sent ANTHROPIC_API_KEY
|
||||
// to the Ollama base URL.
|
||||
func TestFromEnvProviderIsCaseInsensitive(t *testing.T) {
|
||||
t.Setenv("ANTHROPIC_API_KEY", "sk-ant-secret")
|
||||
t.Setenv("OPENAI_API_KEY", "sk-oai")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "")
|
||||
t.Setenv("ARTEX_LLM_MODEL", "")
|
||||
for _, tc := range []struct {
|
||||
env string
|
||||
want llm.Format
|
||||
key string
|
||||
}{
|
||||
{"Ollama", FormatOllama, ""},
|
||||
{" OLLAMA ", FormatOllama, ""},
|
||||
{"OpenAI", llm.FormatOpenAI, "sk-oai"},
|
||||
{"OpenAI-Responses", llm.FormatOpenAIResponses, "sk-oai"},
|
||||
{"Anthropic", llm.FormatAnthropic, "sk-ant-secret"},
|
||||
} {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", tc.env)
|
||||
c, ok := FromEnv()
|
||||
if !ok {
|
||||
t.Fatalf("%q: FromEnv returned ok=false", tc.env)
|
||||
}
|
||||
if c.Format != tc.want || c.APIKey != tc.key {
|
||||
t.Fatalf("%q: format=%v key=%q, want format=%v key=%q", tc.env, c.Format, c.APIKey, tc.want, tc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -88,7 +88,11 @@ func run() int {
|
||||
}
|
||||
defer mgr.Close()
|
||||
|
||||
skillDir := config.SkillDir()
|
||||
skillDir, err := config.SkillDir()
|
||||
if err != nil {
|
||||
log.Printf("[config] %v", err)
|
||||
return 1
|
||||
}
|
||||
if abs, err := filepath.Abs(skillDir); err == nil {
|
||||
skillDir = abs
|
||||
}
|
||||
|
||||
+33
-14
@@ -4,7 +4,9 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -96,16 +98,23 @@ func Path() string {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// Load reads and parses the config file. A missing/unreadable file yields a zero
|
||||
// Config (so callers fall back to defaults) rather than an error.
|
||||
func Load() Config {
|
||||
// Load reads and parses the config file. A missing file yields a zero Config (so
|
||||
// callers fall back to defaults). A file that exists but cannot be read or parsed
|
||||
// is an error: ignoring it would run with defaults the operator did not choose.
|
||||
func Load() (Config, error) {
|
||||
var c Config
|
||||
b, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
return c
|
||||
path := Path()
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
return c
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("read config file %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config file %s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SkillDir returns the skill root directory with precedence:
|
||||
@@ -113,17 +122,23 @@ func Load() Config {
|
||||
// env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills
|
||||
//
|
||||
// The directory is created if it does not exist.
|
||||
func SkillDir() string {
|
||||
func SkillDir() (string, error) {
|
||||
var d string
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
|
||||
d = v
|
||||
} else if v := strings.TrimSpace(Load().SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if v := strings.TrimSpace(c.SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
}
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// PostgresDSN resolves the connection string with precedence:
|
||||
@@ -138,7 +153,11 @@ func PostgresDSN() (dsn, source string, err error) {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
||||
return v, "environment variable ARTEX_PG_DSN", nil
|
||||
}
|
||||
db := Load().Database
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
db := c.Database
|
||||
if d := strings.TrimSpace(db.DSN); d != "" {
|
||||
return d, "config file " + Path() + " (database.dsn)", nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -40,3 +41,24 @@ func TestPostgresDSNPrecedence(t *testing.T) {
|
||||
t.Fatalf("file dsn verbatim, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A config file that exists but is not valid JSON must fail loudly, not run with
|
||||
// defaults (or report "no database config") as if the file were absent.
|
||||
func TestMalformedConfigIsAnError(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// What install.sh used to write for a password containing a double quote.
|
||||
os.WriteFile(cfgPath, []byte(`{"database":{"host":"h","password":"a"b","dbname":"d"},"skill_dir":"/s"}`), 0o644)
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
t.Setenv("ARTEX_SKILL_DIR", "")
|
||||
|
||||
if _, _, err := PostgresDSN(); err == nil || !strings.Contains(err.Error(), "parse config file") {
|
||||
t.Fatalf("PostgresDSN: want a parse error, got %v", err)
|
||||
}
|
||||
if d, err := SkillDir(); err == nil {
|
||||
t.Fatalf("SkillDir: want a parse error, got dir %q", d)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: want a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Autumn-27/artex
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
github.com/Autumn-27/norma v0.4.3
|
||||
@@ -11,9 +11,9 @@ require (
|
||||
github.com/lqqyt2423/go-mitmproxy v1.9.2
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/projectdiscovery/dnsx v1.2.3
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/net v0.62.0
|
||||
golang.org/x/text v0.43.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
)
|
||||
|
||||
@@ -51,10 +51,10 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/tools v0.51.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -106,23 +106,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
+19
-11
@@ -9,6 +9,16 @@ warn(){ printf '\033[33m[!]\033[0m %s\n' "$*"; }
|
||||
die(){ printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
ask(){ local p="$1" d="${2:-}" a; read -rp "$p${d:+ [$d]}: " a; echo "${a:-$d}"; }
|
||||
rand(){ head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24; }
|
||||
# json_str prints $1 as a JSON string literal: backslash, double quote and the
|
||||
# control characters read -r can return are escaped.
|
||||
json_str(){
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"; s="${s//$'\r'/\\r}"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
# sed_repl escapes $1 for the replacement side of s|...|...|: \, | and &.
|
||||
sed_repl(){ printf '%s' "$1" | sed 's/[\\|&]/\\&/g'; }
|
||||
|
||||
# ── docker environment check / auto-install ───────────────────
|
||||
ensure_docker(){
|
||||
@@ -38,17 +48,14 @@ install_docker(){
|
||||
local pw key
|
||||
pw="$(ask 'Postgres password (press Enter to generate randomly)' "$(rand)")"
|
||||
key="$(ask 'ANTHROPIC_API_KEY (may be empty, configure later in the UI)' '')"
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${pw}|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${key}|" .env
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(sed_repl "$pw")|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_repl "$key")|" .env
|
||||
rm -f .env.bak
|
||||
ok ".env generated (POSTGRES_PASSWORD set)"
|
||||
else
|
||||
info "reusing existing .env"
|
||||
fi
|
||||
info "building the image from this checkout and starting..."
|
||||
if [ ! -f "dist/amd64/artex" ] && [ ! -f "dist/arm64/artex" ]; then
|
||||
warn "no prebuilt binary under dist/ - build it first (frontend, then go build -tags embedui); see the Dockerfile header"
|
||||
fi
|
||||
docker compose up -d --build
|
||||
ok "started → http://localhost:8787"
|
||||
info "view logs: docker compose logs -f artex"
|
||||
@@ -76,16 +83,17 @@ install_local(){
|
||||
DB_SSL="$(ask 'sslmode (disable/require)' disable)" ;;
|
||||
esac
|
||||
|
||||
# generate config.json
|
||||
# generate config.json (string values JSON-escaped: a password may contain " or \)
|
||||
case "$DB_PORT" in (*[!0-9]*|'') die "port must be a number: $DB_PORT" ;; esac
|
||||
cat > config.json <<JSON
|
||||
{
|
||||
"database": {
|
||||
"host": "${DB_HOST}",
|
||||
"host": $(json_str "$DB_HOST"),
|
||||
"port": ${DB_PORT},
|
||||
"user": "${DB_USER}",
|
||||
"password": "${DB_PASS}",
|
||||
"dbname": "${DB_NAME}",
|
||||
"sslmode": "${DB_SSL}"
|
||||
"user": $(json_str "$DB_USER"),
|
||||
"password": $(json_str "$DB_PASS"),
|
||||
"dbname": $(json_str "$DB_NAME"),
|
||||
"sslmode": $(json_str "$DB_SSL")
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -34,3 +36,30 @@ func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) {
|
||||
t.Errorf("non-secret variable did not reach the child process")
|
||||
}
|
||||
}
|
||||
|
||||
// New must apply the options it receives. An earlier version accepted
|
||||
// ...Option and never called them, so the binary kept the secrets in the
|
||||
// environment while the unit test above still passed.
|
||||
func TestNewAppliesSecretScrubOption(t *testing.T) {
|
||||
// t.Setenv restores both after the test: the scrub unsets them for the rest
|
||||
// of this process, and later tests still need the DSN.
|
||||
if dsn := os.Getenv("ARTEX_PG_DSN"); dsn != "" {
|
||||
t.Setenv("ARTEX_PG_DSN", dsn)
|
||||
}
|
||||
t.Setenv("ANTHROPIC_API_KEY", "scrub-probe")
|
||||
t.Setenv("OPENAI_API_KEY", "scrub-probe")
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("no manager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_ = New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir(), WithSecretScrub())
|
||||
|
||||
if os.Getenv("ANTHROPIC_API_KEY") != "" || os.Getenv("OPENAI_API_KEY") != "" {
|
||||
t.Fatal("New did not apply WithSecretScrub: the secrets are still in the environment")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestSameLLMEndpoint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provA, baseA, provB, baseB string
|
||||
want bool
|
||||
}{
|
||||
{"anthropic", "https://gw.example", "anthropic", "https://gw.example/", true},
|
||||
{"anthropic", "https://gw.example/v1/messages", "anthropic", "https://gw.example", true},
|
||||
{"", "", "anthropic", "", true}, // empty provider defaults to anthropic
|
||||
{"openai", " https://gw.example/v1/chat/completions ", "openai", "https://gw.example/v1", true},
|
||||
{"anthropic", "https://evil.example", "anthropic", "https://gw.example", false},
|
||||
{"anthropic", "", "anthropic", "https://gw.example", false}, // canonical host != saved host
|
||||
{"openai", "", "anthropic", "", false}, // different canonical hosts
|
||||
{"openai", "https://gw.example/v1", "openai-responses", "https://gw.example/v1", false},
|
||||
} {
|
||||
if got := sameLLMEndpoint(tc.provA, tc.baseA, tc.provB, tc.baseB); got != tc.want {
|
||||
t.Errorf("sameLLMEndpoint(%q,%q,%q,%q)=%v want %v", tc.provA, tc.baseA, tc.provB, tc.baseB, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyRecorder is a fake Anthropic endpoint that records the x-api-key it receives.
|
||||
func keyRecorder(t *testing.T) (*httptest.Server, *atomic.Int32, *atomic.Value) {
|
||||
var hits atomic.Int32
|
||||
var key atomic.Value
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
key.Store(r.Header.Get("x-api-key"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"data":[]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits, &key
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.HandlerFunc, path string, body map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
// The global key must not follow a base_url typed into the test form.
|
||||
func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
|
||||
saved, savedHits, savedKey := keyRecorder(t)
|
||||
other, otherHits, _ := keyRecorder(t)
|
||||
s := &Server{}
|
||||
s.llmCfg = agent.ConfigFrom("anthropic", "m", saved.URL, "global-secret", "")
|
||||
|
||||
resp := postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "streaming": false,
|
||||
})
|
||||
if otherHits.Load() != 0 || resp["ok"] != false {
|
||||
t.Fatalf("global key went to a form-supplied host: hits=%d resp=%v", otherHits.Load(), resp)
|
||||
}
|
||||
|
||||
postJSON(t, s.testLLM, "/api/llm/test", map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": saved.URL + "/", "api_key": "", "streaming": false,
|
||||
})
|
||||
if k, _ := savedKey.Load().(string); savedHits.Load() != 1 || k != "global-secret" {
|
||||
t.Fatalf("global key not used for its own endpoint: hits=%d key=%q", savedHits.Load(), k)
|
||||
}
|
||||
}
|
||||
|
||||
// A stored profile's key goes only to that profile's base URL, for both the
|
||||
// connection test and the model listing.
|
||||
func TestStoredProfileKeyOnlyForStoredEndpoint(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
s.cfgMu.Lock()
|
||||
s.llmCfg = agent.Config{} // no global key to fall back on
|
||||
s.cfgMu.Unlock()
|
||||
|
||||
saved, savedHits, savedKey := keyRecorder(t)
|
||||
other, otherHits, _ := keyRecorder(t)
|
||||
pid, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: "key-endpoint", Format: "anthropic", Model: "m", BaseURL: saved.URL, APIKey: "profile-secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
|
||||
|
||||
for _, ep := range []struct {
|
||||
path string
|
||||
h http.HandlerFunc
|
||||
}{{"/api/llm/test", s.testLLM}, {"/api/llm/models", s.pgListModels}} {
|
||||
resp := postJSON(t, ep.h, ep.path, map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": other.URL, "api_key": "", "profile_id": pid, "streaming": false,
|
||||
})
|
||||
if otherHits.Load() != 0 || resp["ok"] != false {
|
||||
t.Fatalf("%s: stored key went to a form-supplied host: hits=%d resp=%v", ep.path, otherHits.Load(), resp)
|
||||
}
|
||||
before := savedHits.Load()
|
||||
postJSON(t, ep.h, ep.path, map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": saved.URL, "api_key": "", "profile_id": pid, "streaming": false,
|
||||
})
|
||||
if k, _ := savedKey.Load().(string); savedHits.Load() == before || k != "profile-secret" {
|
||||
t.Fatalf("%s: stored key not used for its own endpoint: key=%q", ep.path, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-6
@@ -156,6 +156,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
s.initSetupToken()
|
||||
s.initSideQuestions()
|
||||
// Circuit-breaker thresholds/cooldowns are hot parameters on the failure path; push the global retry policy to the Registry once at startup,
|
||||
@@ -1466,19 +1469,38 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.NeedsAPIKey() {
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// A saved key is only sent to the endpoint it was saved for: base_url and
|
||||
// provider come from the form, so without this check the test button sends
|
||||
// the saved key to any host typed into the form.
|
||||
withheld := false
|
||||
if stored != nil && cfg.APIKey == "" {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, stored.Format, stored.BaseURL) {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
s.cfgMu.Lock()
|
||||
cfg.APIKey = s.llmCfg.APIKey
|
||||
global := s.llmCfg
|
||||
s.cfgMu.Unlock()
|
||||
if global.APIKey != "" {
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, global.Provider(), global.BaseURL) {
|
||||
cfg.APIKey = global.APIKey
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
|
||||
msg := "no API key provided"
|
||||
if withheld {
|
||||
msg = "no API key provided: the saved key is only sent to the endpoint it was saved for; enter the key to test a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1496,6 +1518,16 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// sameLLMEndpoint reports whether two provider/base URL pairs resolve to the same
|
||||
// endpoint after ConfigFrom's normalization (trimmed, endpoint suffix stripped;
|
||||
// an empty base URL means the provider's canonical host). A saved API key may only
|
||||
// be sent when the request's endpoint matches the one it was saved for.
|
||||
func sameLLMEndpoint(provA, baseA, provB, baseB string) bool {
|
||||
a := agent.ConfigFrom(provA, "", baseA, "", "")
|
||||
b := agent.ConfigFrom(provB, "", baseB, "", "")
|
||||
return a.Format == b.Format && a.BaseURL == b.BaseURL
|
||||
}
|
||||
|
||||
// truncateReply clips a connection-test reply for display. A model told to answer
|
||||
// "OK" can still ramble (or think out loud); the UI only needs enough to show it
|
||||
// really said something. Rune-based so multibyte text never splits mid-character.
|
||||
|
||||
+13
-3
@@ -2053,17 +2053,27 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// Resolve API key: form input > profile stored key.
|
||||
// Resolve API key: form input > profile stored key. The stored key is only
|
||||
// sent to the endpoint it was saved for, since base_url comes from the form.
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
withheld := false
|
||||
if apiKey == "" && req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
apiKey = p.APIKey
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, p.Format, p.BaseURL) {
|
||||
apiKey = p.APIKey
|
||||
} else {
|
||||
withheld = p.APIKey != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
// Ollama serves its model list keyless on the local server: no key gate.
|
||||
if apiKey == "" && provider != "ollama" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
|
||||
msg := "no API Key provided"
|
||||
if withheld {
|
||||
msg = "no API Key provided: the saved key is only sent to the endpoint it was saved for; enter the key to list models from a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user