mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc35dddee8 | ||
|
|
668e099dd8 | ||
|
|
04777513b6 | ||
|
|
90e7b3ef55 | ||
|
|
b100848ebf | ||
|
|
0093781c8b | ||
|
|
e94471609f | ||
|
|
77e2c5f9fa | ||
|
|
41fe40af07 | ||
|
|
0d9602dc56 | ||
|
|
5e872ccc15 | ||
|
|
d3c4d21086 | ||
|
|
69831f5cdf | ||
|
|
3322ecd6a6 | ||
|
|
7b2a5fb05e | ||
|
|
2d373ae77a | ||
|
|
fc08133ab2 | ||
|
|
334f6fdddc | ||
|
|
a20628d334 | ||
|
|
a6e3fd0a2e | ||
|
|
ddddddd1f3 | ||
|
|
f3f9b8d4fd | ||
|
|
23cf14cf0a | ||
|
|
5bad722593 | ||
|
|
2aab3e8210 | ||
|
|
d45f0c7811 | ||
|
|
e6f4de5ddc | ||
|
|
ac601b7e59 | ||
|
|
9ebc8b6703 | ||
|
|
1c35c9cf68 | ||
|
|
1ed154e86c | ||
|
|
4034ce4562 | ||
|
|
7f8c7cbcae | ||
|
|
9e63a8031b | ||
|
|
978f308a9e | ||
|
|
80a1ee96f6 |
+4
-1
@@ -1,5 +1,8 @@
|
||||
# Docker deployment configuration (copy to .env)
|
||||
ARTEX_TAG=latest
|
||||
# The image is built locally by `docker compose build`; there is no registry tag.
|
||||
# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0
|
||||
# to reach it from another machine or from Windows when ARTEX runs inside WSL.
|
||||
ARTEX_BIND_ADDR=127.0.0.1
|
||||
POSTGRES_USER=artex
|
||||
POSTGRES_PASSWORD=change-me-please
|
||||
POSTGRES_DB=artex
|
||||
|
||||
@@ -44,6 +44,26 @@ jobs:
|
||||
# as rotating failures in the agent package). Serial package execution
|
||||
# keeps the database state deterministic.
|
||||
run: go test -p 1 ./...
|
||||
- name: go test -race
|
||||
# Race detector on the packages that pass under -race today. ./server is
|
||||
# left out until it has been checked with -race against postgres.
|
||||
# Its own database: the plain run above leaves rows behind and the db tests
|
||||
# are not idempotent across runs (duplicate llm_profiles names).
|
||||
env:
|
||||
ARTEX_PG_DSN: postgres://artex:testpass@localhost:5432/artextest_race?sslmode=disable
|
||||
|
||||
run: >-
|
||||
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh
|
||||
|
||||
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/
|
||||
|
||||
+55
-4
@@ -4,13 +4,62 @@ All notable changes to this project are documented in this file. The format is b
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-11-04
|
||||
|
||||
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
|
||||
|
||||
### Added
|
||||
|
||||
- **Ollama support**: an `ollama` LLM profile format for a local or LAN Ollama server. Keyless by design: no API key is needed, `OPENAI_API_KEY` is never inherited, the base URL defaults to `http://127.0.0.1:11434/v1` and is normalized to the `/v1` root, and the profile works in the failover pool, task LLM chains, model listing and connection tests. Contributed by @glauberportella (#128).
|
||||
- **Setup token for first-run setup**: while no admin password is set, the server generates a random 128-bit one-time token at startup and prints it to its log (`docker compose logs artex | grep "Setup token"`). The `/setup` page has a field for it, `POST /api/auth/init` requires it (`setup_token`, compared in constant time, 403 when missing or wrong), and it is cleared once the password is stored (#137).
|
||||
- **`ARTEX_BIND_ADDR`**: the address `docker-compose.yml` publishes the web UI on, default `127.0.0.1` (#137).
|
||||
|
||||
### Security
|
||||
|
||||
- **First-run claim**: before, anyone who reached port 8787 on a fresh install before the operator could set the admin password. Init now needs the setup token above, and Docker installs publish 8787 on `127.0.0.1` by default; set `ARTEX_BIND_ADDR=0.0.0.0` in `.env`, or use a reverse proxy or SSH tunnel, for remote access (#137).
|
||||
- **Startup secrets removed from the environment**: `ARTEX_PG_DSN`, `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are unset inside `server.New` after they are read and before the MCP discovery goroutine and the task resume loop start, so agent shells, MCP stdio servers and custom tools spawned afterwards no longer inherit them (#136). The binary passes the option that enables this, but `New` did not apply its options, so the scrub from #136 never ran until #146 fixed it (#146). See Known issues for what this does not cover.
|
||||
- **Saved API keys go only to their own endpoint**: `POST /api/llm/test` and `POST /api/llm/models` used a saved key (the profile's, or the global one) for any `base_url` in the request body, so a crafted request could send it to another host. A saved key is now sent only when the request's provider and normalized base URL match the ones it was saved for; otherwise the request must carry `api_key` (#142).
|
||||
- **`ARTEX_LLM_PROVIDER` is matched case-insensitively**: `Ollama` or a value with surrounding spaces fell through to the Anthropic default and sent `ANTHROPIC_API_KEY` to the configured base URL (#140).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ollama base URL on the environment path**: `ARTEX_LLM_BASE_URL=http://host:11434` is normalized to the `/v1` root the same way the saved-profile path already was, so a LAN Ollama server configured through the environment no longer answers 404 on chat (direct commit 1c35c9c).
|
||||
- **Malformed `config.json` is an error**: `config.Load` used to ignore a parse error and treat the file as missing, which surfaced as a misleading "no database config found" or a silently ignored `skill_dir`. A read or parse error is now returned with the file path and startup exits (#141).
|
||||
- **`install.sh` escapes what it writes**: the local install writes `config.json` string values through a JSON escaper and requires a numeric port; the Docker install escapes `\`, `|` and `&` in the Postgres password and API key before the `.env` substitution (#141).
|
||||
- **`scripts/backup.sh --keep 0` is rejected**: it used to delete every dump, including the one just written, and still report success. Values with a leading zero are rejected too (#139).
|
||||
- **`backups/` is ignored again**: a missing newline had merged it into the previous `.gitignore` line, so database dumps showed up as untracked files (#138).
|
||||
|
||||
### Build and CI
|
||||
|
||||
- **The image is built from the checkout instead of pulled**: `docker-compose.yml` no longer references the `autumn27/artex` namespace, whose repository is gone and whose name its holder could republish at any time. `install.sh` and `update.sh` build the image locally (direct commit 1ed154e).
|
||||
- **A fresh clone builds the image**: the Dockerfile copied a prebuilt `dist/<arch>/artex` that a clean checkout does not have. It is now a multi-stage build: a Node 22 stage runs the frontend static export, a Go 1.26 stage runs `build.sh` with the release binaries job's flags (CGO-free cross-compile, `-trimpath`, version stamp), and the runtime stage copies the result (#147).
|
||||
- **`update.sh` refuses to rebuild stale code**: when `git pull --ff-only` fails on a diverged clone (including every clone made before the 2026-10-23 history rewrite), it now stops with recovery commands instead of warning, rebuilding the old tree and printing "Build complete" (direct commit 1ed154e).
|
||||
- **CI runs the race detector and shellcheck**: `go test -race -p 1` over every package with tests except `./server`, and `shellcheck -S warning` on `install.sh`, `update.sh`, `start.sh` and `scripts/backup.sh` (#144). The race run uses its own database (direct commit 0477751).
|
||||
|
||||
### Upgrades
|
||||
|
||||
- **Go 1.26.10** in `go.mod` (was 1.26.3), and **`golang.org/x/net` v0.62.0** (was v0.58.0), which raised x/crypto to 0.58.0, x/text to 0.43.0, x/sys to 0.49.0, x/sync to 0.24.0, x/mod to 0.41.0 and x/tools to 0.51.0 (#145).
|
||||
- **Node 22 in the runtime image**: NodeSource `setup_22.x` instead of the end-of-life `setup_20.x`, matching CI and the release workflow (#143).
|
||||
|
||||
### Known issues
|
||||
|
||||
- **The 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
|
||||
|
||||
First release of this fork. The engine underneath is still upstream's, but this tree carries a long stretch of operational work on top of it: a recon toolbox and skills, findings discipline, cost accounting, and container packaging.
|
||||
|
||||
### Added
|
||||
|
||||
- **Recon toolbox in the image**: pinned nuclei, httpx, subfinder, katana, ffuf, dalfox and a sqlmap checkout, verified at build time, so hunt workers can call them directly (#112).
|
||||
- **Recon toolbox in the image**: nuclei, httpx, subfinder, katana, ffuf and dalfox installed at release-pinned versions, plus a sqlmap checkout that tracks the default branch, each smoke-tested at build time (run once, not checksum-verified), so hunt workers can call them directly (#112). Checksum verification arrives with #127.
|
||||
- **Recursive recon skill** (`bbot-recon`): bbot v3.0.2 in its own virtualenv with module dependencies preinstalled. Policy-gated modes (passive seeds-only when scanners are banned, active-safe, loud only on explicit permission) and JSONL ingestion into the facts graph (#118).
|
||||
- **php2shell skill**: turns a file-inclusion finding into verified RCE through the session.upload_progress chain, lab first and gated on program policy (#113).
|
||||
- **Code audit and reverse-engineering skills** (#55, #77).
|
||||
@@ -30,9 +79,10 @@ First release of this fork. The engine underneath is still upstream's, but this
|
||||
- **Planner quiet mode** for less narration during long runs (#81, #88).
|
||||
- **Findings discipline**: proof-basis gates, measured impact, adversarial verification and dedup rules decide what a finding must contain before it is reported (#51, #53, #63, #69).
|
||||
- **Prompt-injection hardening** on seed material and MCP tool descriptions (#49).
|
||||
- **Pause preserves the task timeout budget**, and paused time is frozen in the task clock (#2, #65, #110).
|
||||
- **Pause preserves the task timeout budget**, and paused time is frozen in the task clock (#2, #110).
|
||||
- **Deadline contract** in the executor seeds (#65).
|
||||
- **Task detail metrics and outcome cards** summarise a finished run (#92, #102).
|
||||
- **Skill-gap, loop-contract and shadow-lesson reporting** for the planner (#104, #106, #108).
|
||||
- **Skill-gap and loop-contract reporting** for the planner (#104, #106); the RSI notes land as documentation in #108.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -920,7 +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.
|
||||
|
||||
@@ -84,10 +84,13 @@ cd ARTEX
|
||||
|
||||
The script will: detect / auto-install Docker → then let you choose **① all-Docker** or **② local build and run**:
|
||||
|
||||
- **① All-Docker**: enter a Postgres password (press Enter for a random one) → auto-writes `.env` → `docker compose up -d`.
|
||||
- **① All-Docker**: enter a Postgres password (press Enter for a random one) → auto-writes `.env` → `docker compose up -d --build` (the image is built from this checkout).
|
||||
- **② Local run**: choose a database (connect to an existing one / start one with Docker) → generates `config.json` → `go` builds the embedded single binary → starts.
|
||||
|
||||
Once installed, open **http://localhost:8787** (on first entry, `/setup` sets the admin password).
|
||||
`/setup` asks for a one-time setup token: while no password is set, the backend prints it at startup in its log (`docker compose logs artex | grep "Setup token"`, or the terminal running `./start.sh`).
|
||||
|
||||
The web UI is published on `127.0.0.1` by default. To reach it from another machine, or from Windows when ARTEX runs inside WSL, set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the first-run claim), or keep loopback and use a reverse proxy or an SSH tunnel.
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
@@ -95,10 +98,14 @@ Once installed, open **http://localhost:8787** (on first entry, `/setup` sets th
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
docker compose up -d # pulls the autumn27/artex image + postgres
|
||||
# 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
|
||||
```
|
||||
|
||||
> The image is built locally. There is no published image to pull: the previous
|
||||
> default pointed at a third-party namespace that no longer hosts this project.
|
||||
|
||||
The image already includes common tools (ripgrep/curl/vim/npm/nmap…); `./skills` and `./data` are persisted via bind mounts.
|
||||
|
||||
For remote MCP, you can choose `http` (Streamable HTTP) or `sse` (legacy SSE) in system settings.
|
||||
@@ -175,6 +182,19 @@ Daily cron example (03:20, operator crontab):
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (jwt.key / SQLite, etc.), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
### Upgrading from a clone made before 2026-10-23
|
||||
|
||||
The repository history was rewritten that day to drop a personal email address from the commit metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of silently rebuilding stale code. Recover once, then upgrade normally:
|
||||
|
||||
```bash
|
||||
git stash # only if you have local changes
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git stash pop # only if you stashed
|
||||
```
|
||||
|
||||
Your data is not affected: `./data`, the `pgdata` volume and `./skills` are not part of the git history.
|
||||
|
||||
### Option 1: one-click update script
|
||||
|
||||
```bash
|
||||
@@ -184,17 +204,16 @@ cd ARTEX
|
||||
|
||||
The script first optionally `git pull`s the latest code, then lets you choose **① Docker update** or **② local build update** (matching `install.sh`):
|
||||
|
||||
- **① Docker**: you can specify a target image tag (press Enter to keep `.env`'s `ARTEX_TAG`, default `latest`) → `docker compose pull` → `docker compose up -d` (restarting with the new image migrates automatically).
|
||||
- **① Docker**: rebuilds the image from this checkout → `docker compose build artex` → `docker compose up -d artex` (restarting migrates the schema automatically).
|
||||
- **② Local**: rebuild the frontend static assets → recompile `./artex` (restart the process for it to take effect).
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
git pull # update compose / scripts (optional)
|
||||
# pin a version: set ARTEX_TAG=v0.2.0 in .env; otherwise latest is used
|
||||
docker compose pull artex
|
||||
docker compose up -d artex # restart with the new image → schema migrates automatically
|
||||
git pull # update compose / scripts
|
||||
docker compose build artex # rebuild the image from this checkout
|
||||
docker compose up -d artex # recreate the container → schema migrates automatically
|
||||
docker image prune -f # clean up old images (optional)
|
||||
```
|
||||
|
||||
@@ -233,8 +252,8 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
}
|
||||
```
|
||||
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page.
|
||||
Optional: `ARTEX_LLM_PROVIDER` / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server).
|
||||
Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
|
||||
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
|
||||
|
||||
|
||||
+97
-11
@@ -3,8 +3,10 @@
|
||||
// §4.3 (planner) and §4.4 (work agent).
|
||||
//
|
||||
// Provider configuration is read from the environment so the system runs with
|
||||
// any Anthropic- or OpenAI-format endpoint. If no key is configured, FromEnv
|
||||
// returns ok=false and the exploration engine stays idle (an LLM is required).
|
||||
// any Anthropic-, OpenAI- or Ollama-format endpoint (the last being keyless).
|
||||
// If no usable configuration is found — a key for the cloud formats, a base
|
||||
// URL for keyless Ollama — FromEnv returns ok=false and the exploration engine
|
||||
// stays idle (an LLM is required).
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -125,6 +127,47 @@ const (
|
||||
maxWindowK = 1000 // cap at 1M tokens (user request)
|
||||
)
|
||||
|
||||
// FormatOllama marks a LOCAL Ollama server profile. Ollama exposes an
|
||||
// OpenAI-compatible Chat Completions endpoint (/v1), so on the wire it speaks
|
||||
// llm.FormatOpenAI; the distinct sentinel matters everywhere a profile must be
|
||||
// treated as usable WITHOUT an API key — Ollama runs keyless on localhost/LAN.
|
||||
// See NeedsAPIKey and NewProvider (which maps it onto FormatOpenAI while
|
||||
// forcing our base-URL/key defaults so an empty key never falls back to
|
||||
// OpenAI's public endpoint).
|
||||
const FormatOllama llm.Format = "ollama"
|
||||
|
||||
// DefaultOllamaBaseURL is the base of Ollama's OpenAI-compatible endpoint the
|
||||
// provider appends /chat/completions to. Must never end up empty: with an
|
||||
// empty BaseURL, the SDK would fall back to OpenAI's public endpoint.
|
||||
const DefaultOllamaBaseURL = "http://127.0.0.1:11434/v1"
|
||||
|
||||
// ollamaPlaceholderKey is sent as the Bearer token when no key is configured.
|
||||
// Ollama ignores authorization, but a concrete value keeps the SDK from
|
||||
// falling back to OPENAI_API_KEY (which would silently ship a real cloud key
|
||||
// to the local server) and keeps the header stable.
|
||||
const ollamaPlaceholderKey = "ollama"
|
||||
|
||||
// normalizeOllamaBaseURL maps whatever the operator typed onto the /v1 root the
|
||||
// OpenAI-compatible provider expects: it strips a pasted "/chat/completions"
|
||||
// suffix, an empty value becomes the local default, and a missing "/v1" is
|
||||
// appended so the natural "http://host:11434" also works. Shared by the saved
|
||||
// profile path (ConfigFrom) and the environment path (FromEnv): both used to
|
||||
// differ, and only the profile path normalized.
|
||||
func normalizeOllamaBaseURL(raw string) string {
|
||||
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSpace(raw), "/chat/completions"), "/")
|
||||
if b == "" {
|
||||
return DefaultOllamaBaseURL
|
||||
}
|
||||
if !strings.HasSuffix(b, "/v1") {
|
||||
b += "/v1"
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// NeedsAPIKey reports whether this provider format requires an API key to be
|
||||
// usable. Ollama (local server) is keyless; every cloud format needs one.
|
||||
func (c Config) NeedsAPIKey() bool { return c.Format != FormatOllama }
|
||||
|
||||
// CompactionWindow returns the model context window in TOKENS for compaction
|
||||
// thresholds, resolved from the user-configured size (ContextWindowK). 0/unset →
|
||||
// a 200K default; otherwise clamped to [32K, 1M] so compaction stays effective.
|
||||
@@ -154,13 +197,15 @@ func compactionConfig(windowTokens int) *compaction.Config {
|
||||
|
||||
// FromEnv resolves the LLM provider config:
|
||||
//
|
||||
// ARTEX_LLM_PROVIDER = anthropic|openai (default: inferred from keys)
|
||||
// ARTEX_LLM_PROVIDER = anthropic|openai|openai-responses|ollama (default: inferred from keys)
|
||||
// ARTEX_LLM_MODEL = model id (default: per provider)
|
||||
// ARTEX_LLM_BASE_URL = endpoint (optional)
|
||||
// ARTEX_LLM_BASE_URL = endpoint (optional; ollama defaults to localhost:11434/v1)
|
||||
// ARTEX_LLM_PROXY = proxy URL (optional; http/https/socks5)
|
||||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials
|
||||
// ANTHROPIC_API_KEY / OPENAI_API_KEY = credentials (unused for ollama)
|
||||
func FromEnv() (Config, bool) {
|
||||
prov := os.Getenv("ARTEX_LLM_PROVIDER")
|
||||
// Case- and space-insensitive: "Ollama" used to miss every case below and
|
||||
// fall through to anthropic, sending ANTHROPIC_API_KEY to the Ollama base URL.
|
||||
prov := strings.ToLower(strings.TrimSpace(os.Getenv("ARTEX_LLM_PROVIDER")))
|
||||
anthKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
oaiKey := os.Getenv("OPENAI_API_KEY")
|
||||
|
||||
@@ -196,6 +241,16 @@ func FromEnv() (Config, bool) {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
case "ollama":
|
||||
c.Format = FormatOllama
|
||||
// Deliberately NOT inheriting OPENAI_API_KEY: a local Ollama ignores the
|
||||
// bearer token, so the only thing the inheritance could do is ship a real
|
||||
// cloud key to the endpoint. A gateway that does require a key can still
|
||||
// be configured as a saved profile with an explicit one.
|
||||
c.BaseURL = normalizeOllamaBaseURL(c.BaseURL)
|
||||
if c.Model == "" {
|
||||
c.Model = "llama3.2"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
c.APIKey = anthKey
|
||||
@@ -203,7 +258,8 @@ func FromEnv() (Config, bool) {
|
||||
c.Model = "claude-opus-4-8"
|
||||
}
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
// Ollama runs without a key: only the cloud formats gate on one.
|
||||
if c.APIKey == "" && c.NeedsAPIKey() {
|
||||
return Config{}, false
|
||||
}
|
||||
return c, true
|
||||
@@ -236,6 +292,16 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
case "ollama":
|
||||
c.Format = FormatOllama
|
||||
// Ollama's OpenAI-compatible endpoint lives at /v1 and the provider
|
||||
// appends "/chat/completions". Never leave it empty: an empty BaseURL
|
||||
// would let the SDK fall back to OpenAI's public endpoint with this
|
||||
// profile's request.
|
||||
c.BaseURL = normalizeOllamaBaseURL(c.BaseURL)
|
||||
if c.Model == "" {
|
||||
c.Model = "llama3.2"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
// provider appends "/v1/messages".
|
||||
@@ -257,13 +323,16 @@ func isFalsy(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Provider returns the short provider name ("anthropic"/"openai").
|
||||
// Provider returns the short provider name
|
||||
// ("anthropic"/"openai"/"openai-responses"/"ollama").
|
||||
func (c Config) Provider() string {
|
||||
switch c.Format {
|
||||
case llm.FormatOpenAI:
|
||||
return "openai"
|
||||
case llm.FormatOpenAIResponses:
|
||||
return "openai-responses"
|
||||
case FormatOllama:
|
||||
return "ollama"
|
||||
}
|
||||
return "anthropic"
|
||||
}
|
||||
@@ -276,10 +345,27 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wireFormat := c.Format
|
||||
baseURL := c.BaseURL
|
||||
apiKey := c.APIKey
|
||||
// Ollama speaks OpenAI's Chat Completions on the wire. Bundle the keyless
|
||||
// defaults here so an Ollama config can never leak toward the SDK's env-
|
||||
// driven fallbacks: BaseURL to OpenAI's public endpoint, APIKey to
|
||||
// OPENAI_API_KEY. A local Ollama ignores the placeholder token; a gateway
|
||||
// that requires one carries a real key in the profile.
|
||||
if wireFormat == FormatOllama {
|
||||
wireFormat = llm.FormatOpenAI
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = DefaultOllamaBaseURL
|
||||
}
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
apiKey = ollamaPlaceholderKey
|
||||
}
|
||||
}
|
||||
lc := llm.Config{
|
||||
Format: c.Format,
|
||||
BaseURL: c.BaseURL,
|
||||
APIKey: c.APIKey,
|
||||
Format: wireFormat,
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Model: c.Model,
|
||||
HTTPClient: client,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -88,12 +88,16 @@ func run() int {
|
||||
}
|
||||
defer mgr.Close()
|
||||
|
||||
skillDir := config.SkillDir()
|
||||
skillDir, err := config.SkillDir()
|
||||
if err != nil {
|
||||
log.Printf("[config] %v", err)
|
||||
return 1
|
||||
}
|
||||
if abs, err := filepath.Abs(skillDir); err == nil {
|
||||
skillDir = abs
|
||||
}
|
||||
log.Printf("[config] skill directory: %s", skillDir)
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir())
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir(), server.WithSecretScrub())
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv.Handler(),
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
)
|
||||
|
||||
// The shutdown cause must survive the parent context being cancelled first;
|
||||
// losing it would drop the diagnostic on every graceful stop.
|
||||
func TestShutdownContextPreservesNamedCause(t *testing.T) {
|
||||
signalCtx, signalCancel := context.WithCancel(context.Background())
|
||||
ctx, shutdown := shutdownContext(signalCtx)
|
||||
|
||||
+33
-14
@@ -4,7 +4,9 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -96,16 +98,23 @@ func Path() string {
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// Load reads and parses the config file. A missing/unreadable file yields a zero
|
||||
// Config (so callers fall back to defaults) rather than an error.
|
||||
func Load() Config {
|
||||
// Load reads and parses the config file. A missing file yields a zero Config (so
|
||||
// callers fall back to defaults). A file that exists but cannot be read or parsed
|
||||
// is an error: ignoring it would run with defaults the operator did not choose.
|
||||
func Load() (Config, error) {
|
||||
var c Config
|
||||
b, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
return c
|
||||
path := Path()
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
_ = json.Unmarshal(b, &c)
|
||||
return c
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("read config file %s: %w", path, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &c); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config file %s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SkillDir returns the skill root directory with precedence:
|
||||
@@ -113,17 +122,23 @@ func Load() Config {
|
||||
// env ARTEX_SKILL_DIR > config file (skill_dir) > BaseDir()/skills
|
||||
//
|
||||
// The directory is created if it does not exist.
|
||||
func SkillDir() string {
|
||||
func SkillDir() (string, error) {
|
||||
var d string
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_SKILL_DIR")); v != "" {
|
||||
d = v
|
||||
} else if v := strings.TrimSpace(Load().SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if v := strings.TrimSpace(c.SkillDir); v != "" {
|
||||
d = v
|
||||
} else {
|
||||
d = filepath.Join(BaseDir(), "skills")
|
||||
}
|
||||
}
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
return d
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// PostgresDSN resolves the connection string with precedence:
|
||||
@@ -138,7 +153,11 @@ func PostgresDSN() (dsn, source string, err error) {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
||||
return v, "environment variable ARTEX_PG_DSN", nil
|
||||
}
|
||||
db := Load().Database
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
db := c.Database
|
||||
if d := strings.TrimSpace(db.DSN); d != "" {
|
||||
return d, "config file " + Path() + " (database.dsn)", nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -40,3 +41,24 @@ func TestPostgresDSNPrecedence(t *testing.T) {
|
||||
t.Fatalf("file dsn verbatim, got %q err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A config file that exists but is not valid JSON must fail loudly, not run with
|
||||
// defaults (or report "no database config") as if the file were absent.
|
||||
func TestMalformedConfigIsAnError(t *testing.T) {
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// What install.sh used to write for a password containing a double quote.
|
||||
os.WriteFile(cfgPath, []byte(`{"database":{"host":"h","password":"a"b","dbname":"d"},"skill_dir":"/s"}`), 0o644)
|
||||
t.Setenv("ARTEX_CONFIG", cfgPath)
|
||||
t.Setenv("ARTEX_PG_DSN", "")
|
||||
t.Setenv("ARTEX_SKILL_DIR", "")
|
||||
|
||||
if _, _, err := PostgresDSN(); err == nil || !strings.Contains(err.Error(), "parse config file") {
|
||||
t.Fatalf("PostgresDSN: want a parse error, got %v", err)
|
||||
}
|
||||
if d, err := SkillDir(); err == nil {
|
||||
t.Fatalf("SkillDir: want a parse error, got dir %q", d)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: want a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -143,13 +143,14 @@ func (d *DB) ProfileByID(id int64) (*LLMProfile, error) {
|
||||
}
|
||||
|
||||
// PoolProfiles returns the failover chain in run order, api keys included: the
|
||||
// active profile first, then every other keyed profile that isn't excluded, by
|
||||
// active profile first, then every other usable profile that isn't excluded, by
|
||||
// priority DESC (id ASC to stay stable). Profiles without an api key can't serve
|
||||
// a request, so they never enter the chain. The ordering IS the policy — callers
|
||||
// walk the slice front to back.
|
||||
// a request — except Ollama ('ollama'), which is a keyless local server — so
|
||||
// they never enter the chain. The ordering IS the policy — callers walk the
|
||||
// slice front to back.
|
||||
func (d *DB) PoolProfiles() ([]*LLMProfile, error) {
|
||||
rows, err := d.Query(`SELECT ` + profileColsKey + ` FROM llm_profiles
|
||||
WHERE COALESCE(api_key,'') <> '' AND (is_default OR NOT pool_exclude)
|
||||
WHERE (COALESCE(api_key,'') <> '' OR format='ollama') AND (is_default OR NOT pool_exclude)
|
||||
ORDER BY is_default DESC, priority DESC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+5
-4
@@ -363,7 +363,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses','ollama')),
|
||||
base_url TEXT,
|
||||
proxy TEXT,
|
||||
model TEXT NOT NULL,
|
||||
@@ -416,11 +416,12 @@ ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Streaming toggle; backfills old databases. Default true = keep existing streaming behavior; old configs upgrade transparently.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true;
|
||||
-- Relax the format constraint to allow openai-responses (OpenAI Responses API); backfills old databases.
|
||||
-- Runs at every startup, idempotent: drop the old CHECK first, then create the new three-value CHECK.
|
||||
-- Relax the format constraint to allow openai-responses (OpenAI Responses API) and
|
||||
-- ollama (local Ollama server, keyless OpenAI-compatible endpoint); backfills old databases.
|
||||
-- Runs at every startup, idempotent: drop the old CHECK first, then create the new four-value CHECK.
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||||
CHECK (format IN ('openai','anthropic','openai-responses','ollama'));
|
||||
|
||||
-- Output cap and its field name; backfills old databases. Defaults 0 / '' = send no cap and keep the max_tokens field name;
|
||||
-- old configs behave exactly as before.
|
||||
|
||||
+9
-2
@@ -15,7 +15,14 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
artex:
|
||||
image: autumn27/artex:${ARTEX_TAG:-latest}
|
||||
# Built from this checkout. The previous reference was a third-party
|
||||
# namespace whose repository is gone: whoever controls that name could
|
||||
# publish the tag this file would pull.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
ARTEX_VERSION: ${ARTEX_VERSION:-dev}
|
||||
image: artex:local
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -28,7 +35,7 @@ services:
|
||||
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ports:
|
||||
- "8787:8787"
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
volumes:
|
||||
# Bind the data directory (jwt.key, SQLite, etc.) to data/ in the project root,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Autumn-27/artex
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
github.com/Autumn-27/norma v0.4.3
|
||||
@@ -11,9 +11,9 @@ require (
|
||||
github.com/lqqyt2423/go-mitmproxy v1.9.2
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/projectdiscovery/dnsx v1.2.3
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/net v0.62.0
|
||||
golang.org/x/text v0.43.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
)
|
||||
|
||||
@@ -51,10 +51,10 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/tools v0.51.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -106,23 +106,23 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
+21
-11
@@ -9,6 +9,16 @@ warn(){ printf '\033[33m[!]\033[0m %s\n' "$*"; }
|
||||
die(){ printf '\033[31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
ask(){ local p="$1" d="${2:-}" a; read -rp "$p${d:+ [$d]}: " a; echo "${a:-$d}"; }
|
||||
rand(){ head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24; }
|
||||
# json_str prints $1 as a JSON string literal: backslash, double quote and the
|
||||
# control characters read -r can return are escaped.
|
||||
json_str(){
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"; s="${s//$'\r'/\\r}"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
# sed_repl escapes $1 for the replacement side of s|...|...|: \, | and &.
|
||||
sed_repl(){ printf '%s' "$1" | sed 's/[\\|&]/\\&/g'; }
|
||||
|
||||
# ── docker environment check / auto-install ───────────────────
|
||||
ensure_docker(){
|
||||
@@ -38,16 +48,15 @@ install_docker(){
|
||||
local pw key
|
||||
pw="$(ask 'Postgres password (press Enter to generate randomly)' "$(rand)")"
|
||||
key="$(ask 'ANTHROPIC_API_KEY (may be empty, configure later in the UI)' '')"
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${pw}|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${key}|" .env
|
||||
sed -i.bak "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(sed_repl "$pw")|" .env
|
||||
sed -i.bak "s|^ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=$(sed_repl "$key")|" .env
|
||||
rm -f .env.bak
|
||||
ok ".env generated (POSTGRES_PASSWORD set)"
|
||||
else
|
||||
info "reusing existing .env"
|
||||
fi
|
||||
info "pulling images and starting..."
|
||||
docker compose pull || true
|
||||
docker compose up -d
|
||||
info "building the image from this checkout and starting..."
|
||||
docker compose up -d --build
|
||||
ok "started → http://localhost:8787"
|
||||
info "view logs: docker compose logs -f artex"
|
||||
}
|
||||
@@ -74,16 +83,17 @@ install_local(){
|
||||
DB_SSL="$(ask 'sslmode (disable/require)' disable)" ;;
|
||||
esac
|
||||
|
||||
# generate config.json
|
||||
# generate config.json (string values JSON-escaped: a password may contain " or \)
|
||||
case "$DB_PORT" in (*[!0-9]*|'') die "port must be a number: $DB_PORT" ;; esac
|
||||
cat > config.json <<JSON
|
||||
{
|
||||
"database": {
|
||||
"host": "${DB_HOST}",
|
||||
"host": $(json_str "$DB_HOST"),
|
||||
"port": ${DB_PORT},
|
||||
"user": "${DB_USER}",
|
||||
"password": "${DB_PASS}",
|
||||
"dbname": "${DB_NAME}",
|
||||
"sslmode": "${DB_SSL}"
|
||||
"user": $(json_str "$DB_USER"),
|
||||
"password": $(json_str "$DB_PASS"),
|
||||
"dbname": $(json_str "$DB_NAME"),
|
||||
"sslmode": $(json_str "$DB_SSL")
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
+3
-1
@@ -31,7 +31,9 @@ while [ $# -gt 0 ]; do
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$KEEP" in (*[!0-9]*|'') echo "--keep must be a positive integer" >&2; exit 2 ;; esac
|
||||
# 0* rejects 0, which would make prune delete the dump this run just wrote, and
|
||||
# leading zeros, which $((KEEP + 1)) would read as octal.
|
||||
case "$KEEP" in (*[!0-9]*|''|0*) echo "--keep must be a positive integer" >&2; exit 2 ;; esac
|
||||
|
||||
# Compose reads .env for credentials; source it for the dump command too.
|
||||
if [ -f .env ]; then
|
||||
|
||||
+39
-1
@@ -2,6 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
@@ -154,7 +156,34 @@ func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
|
||||
}
|
||||
|
||||
// initSetupToken generates the one-time setup token when no admin password is set
|
||||
// and writes it to the server log. Without it, anyone who can reach the port
|
||||
// before the operator could claim the instance through POST /api/auth/init.
|
||||
func (s *Server) initSetupToken() {
|
||||
if s.m == nil || s.m.pg == nil {
|
||||
return
|
||||
}
|
||||
if hash, _, err := s.m.pg.GetSetting(authPassKey); err == nil && hash != "" {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
log.Fatalf("[auth] generate setup token: %v", err)
|
||||
}
|
||||
s.setupToken = hex.EncodeToString(buf)
|
||||
log.Printf("[auth] no admin password is set. Setup token for the /setup page: %s", s.setupToken)
|
||||
}
|
||||
|
||||
// checkSetupToken compares got with the active setup token in constant time.
|
||||
// It fails when no token is active.
|
||||
func (s *Server) checkSetupToken(got string) bool {
|
||||
s.setupMu.Lock()
|
||||
defer s.setupMu.Unlock()
|
||||
return s.setupToken != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.setupToken)) == 1
|
||||
}
|
||||
|
||||
// POST /api/auth/init — sets the password for the first time; rejected if already set.
|
||||
// Requires the setup token from the server log.
|
||||
func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
@@ -170,12 +199,18 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Password string `json:"password"`
|
||||
SetupToken string `json:"setup_token"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil || req.Password == "" {
|
||||
writeErr(w, 400, "Password cannot be empty")
|
||||
return
|
||||
}
|
||||
// 403, not 401: the frontend treats any 401 as an expired session and redirects to /login.
|
||||
if !s.checkSetupToken(req.SetupToken) {
|
||||
writeErr(w, 403, "Invalid setup token. The server log prints it at startup while no password is set")
|
||||
return
|
||||
}
|
||||
if msg := validatePassword(req.Password); msg != "" {
|
||||
writeErr(w, 400, msg)
|
||||
return
|
||||
@@ -197,6 +232,9 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 403, "Password is already set")
|
||||
return
|
||||
}
|
||||
s.setupMu.Lock()
|
||||
s.setupToken = ""
|
||||
s.setupMu.Unlock()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "token generation failed")
|
||||
|
||||
@@ -62,6 +62,62 @@ func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// While no password is set, authInit must reject a missing or wrong setup token
|
||||
// and accept the one generated at startup, once.
|
||||
func TestAuthInitRequiresSetupToken(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v)", err)
|
||||
}
|
||||
defer m.Close()
|
||||
clearPassword := func() {
|
||||
if _, err := m.pg.Exec(`DELETE FROM settings WHERE key=$1`, authPassKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
clearPassword()
|
||||
defer clearPassword()
|
||||
|
||||
s := &Server{m: m, jwtKey: []byte("0123456789abcdef0123456789abcdef")}
|
||||
s.initSetupToken()
|
||||
setupToken := s.setupToken
|
||||
if setupToken == "" {
|
||||
t.Fatal("no setup token generated while no password is set")
|
||||
}
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"missing": `{"password":"correct horse battery"}`,
|
||||
"empty": `{"password":"correct horse battery","setup_token":""}`,
|
||||
"wrong": `{"password":"correct horse battery","setup_token":"0000"}`,
|
||||
} {
|
||||
if w := post(body); w.Code != 403 || strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("%s setup token: status=%d want 403 without a token; body=%s", name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash != "" {
|
||||
t.Fatalf("a rejected request changed the password state: hash set=%v err=%v", hash != "", err)
|
||||
}
|
||||
|
||||
ok := `{"password":"correct horse battery","setup_token":"` + setupToken + `"}`
|
||||
if w := post(ok); w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("right setup token: status=%d want 200 with a token; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if hash, _, err := m.pg.GetSetting(authPassKey); err != nil || hash == "" {
|
||||
t.Fatalf("password not stored: err=%v", err)
|
||||
}
|
||||
|
||||
// The token is spent: even with the password row removed, it is refused.
|
||||
clearPassword()
|
||||
if w := post(ok); w.Code != 403 {
|
||||
t.Fatalf("reused setup token: status=%d want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, pw string
|
||||
|
||||
@@ -103,7 +103,7 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set")
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or has no API Key set")
|
||||
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import "os"
|
||||
|
||||
// secretEnv lists the credentials read from the environment only at startup:
|
||||
// ARTEX_PG_DSN by NewManager, the API keys by agent.FromEnv inside New.
|
||||
var secretEnv = []string{"ARTEX_PG_DSN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"}
|
||||
|
||||
// ScrubSecretEnv removes secretEnv from the process environment once startup has
|
||||
// read it. Child processes (agent shells, custom tools, MCP stdio servers) copy
|
||||
// os.Environ() when they are spawned, so they no longer inherit these values.
|
||||
//
|
||||
// It runs inside New, after both readers and before the MCP discovery goroutine
|
||||
// and the task resume loop: both can spawn children, and resuming a task starts
|
||||
// workers that run shells. Scrubbing in main after New returned left that window
|
||||
// open.
|
||||
func ScrubSecretEnv() {
|
||||
for _, k := range secretEnv {
|
||||
_ = os.Unsetenv(k)
|
||||
}
|
||||
}
|
||||
|
||||
// Option configures New. Options keep the test call sites unchanged: only the
|
||||
// binary opts into scrubbing, because tests create several servers in one
|
||||
// process and still need ARTEX_PG_DSN on later calls.
|
||||
type Option func(*Server)
|
||||
|
||||
// WithSecretScrub makes New drop the startup secrets after reading them, before
|
||||
// the MCP discovery goroutine and the task resume loop can spawn children.
|
||||
func WithSecretScrub() Option { return func(s *Server) { s.scrubSecrets = true } }
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A child process spawned after startup must not see the startup secrets, while
|
||||
// other variables still pass through.
|
||||
func TestScrubSecretEnvHidesSecretsFromChildren(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh in PATH")
|
||||
}
|
||||
for _, k := range secretEnv {
|
||||
t.Setenv(k, "leak-"+k)
|
||||
}
|
||||
t.Setenv("ARTEX_SCRUB_KEEP", "kept")
|
||||
|
||||
ScrubSecretEnv()
|
||||
|
||||
out, err := exec.Command(sh, "-c", "env").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := "\n" + string(out)
|
||||
for _, k := range secretEnv {
|
||||
if strings.Contains(env, "\n"+k+"=") {
|
||||
t.Errorf("%s still reaches child processes", k)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(env, "\nARTEX_SCRUB_KEEP=kept\n") {
|
||||
t.Errorf("non-secret variable did not reach the child process")
|
||||
}
|
||||
}
|
||||
|
||||
// New must apply the options it receives. An earlier version accepted
|
||||
// ...Option and never called them, so the binary kept the secrets in the
|
||||
// environment while the unit test above still passed.
|
||||
func TestNewAppliesSecretScrubOption(t *testing.T) {
|
||||
// t.Setenv restores both after the test: the scrub unsets them for the rest
|
||||
// of this process, and later tests still need the DSN.
|
||||
if dsn := os.Getenv("ARTEX_PG_DSN"); dsn != "" {
|
||||
t.Setenv("ARTEX_PG_DSN", dsn)
|
||||
}
|
||||
t.Setenv("ANTHROPIC_API_KEY", "scrub-probe")
|
||||
t.Setenv("OPENAI_API_KEY", "scrub-probe")
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("no manager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_ = New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir(), WithSecretScrub())
|
||||
|
||||
if os.Getenv("ANTHROPIC_API_KEY") != "" || os.Getenv("OPENAI_API_KEY") != "" {
|
||||
t.Fatal("New did not apply WithSecretScrub: the secrets are still in the environment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// ConfigFrom("ollama", ...) must normalize to the keyless sentinel with our own
|
||||
// defaults, never the SDK's OpenAI-public-endpoint fallback.
|
||||
func TestOllamaConfigFromKeylessDefaults(t *testing.T) {
|
||||
c := agent.ConfigFrom("ollama", "", "", "", "")
|
||||
if c.Format != agent.FormatOllama {
|
||||
t.Fatalf("format=%v, want FormatOllama", c.Format)
|
||||
}
|
||||
if c.Provider() != "ollama" {
|
||||
t.Fatalf("Provider()=%q", c.Provider())
|
||||
}
|
||||
if c.BaseURL != agent.DefaultOllamaBaseURL {
|
||||
t.Fatalf("BaseURL=%q, want default %q", c.BaseURL, agent.DefaultOllamaBaseURL)
|
||||
}
|
||||
if c.Model == "" {
|
||||
t.Fatal("model must default to a non-empty value")
|
||||
}
|
||||
if c.NeedsAPIKey() {
|
||||
t.Fatal("ollama must not require an API key")
|
||||
}
|
||||
// A full endpoint URL is tolerated (the provider appends /chat/completions).
|
||||
e := agent.ConfigFrom("ollama", "llama3.2", "http://box:11434/v1/chat/completions", "", "")
|
||||
if e.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("full endpoint URL not normalized: %q", e.BaseURL)
|
||||
}
|
||||
// A custom key is preserved (e.g. an authenticated Ollama gateway).
|
||||
k := agent.ConfigFrom("ollama", "m", "http://box:11434", "ollama-secret", "")
|
||||
if k.APIKey != "ollama-secret" {
|
||||
t.Fatalf("custom key lost: %q", k.APIKey)
|
||||
}
|
||||
// The natural Ollama URL has no /v1, but the provider appends
|
||||
// /chat/completions, so the base must be normalized to the /v1 root.
|
||||
n := agent.ConfigFrom("ollama", "m", "http://box:11434", "", "")
|
||||
if n.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("bare host URL not normalized: %q", n.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider built from an Ollama config speaks OpenAI Chat Completions on
|
||||
// the wire (the SDK appends /chat/completions) with the placeholder Bearer (no
|
||||
// real key was configured, no OPENAI_API_KEY env leak).
|
||||
func TestOllamaProviderWiresOpenAIProtocol(t *testing.T) {
|
||||
var gotPath, gotAuth, gotModel atomic.Value
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath.Store(r.URL.Path)
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotModel.Store(body.Model)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
cfg := agent.ConfigFrom("ollama", "llama3.2", fake.URL, "", "")
|
||||
cfg.Stream = false // non-streaming: the fake answers one plain JSON body
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req := llm.CompletionRequest{
|
||||
System: []string{"You are a connection test. Output OK."},
|
||||
Messages: []llm.Message{llm.UserText("ping")},
|
||||
}
|
||||
if _, _, _, err = prov.Complete(ctx, req); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
// ConfigFrom normalizes the fake server URL (no /v1) onto the OpenAI root,
|
||||
// so the request must land on /v1/chat/completions. A path of /chat/completions
|
||||
// would mean the base URL leaked through unnormalized (or fell back to OpenAI).
|
||||
if p, _ := gotPath.Load().(string); p != "/v1/chat/completions" {
|
||||
t.Fatalf("path=%q, want /v1/chat/completions", p)
|
||||
}
|
||||
if a, _ := gotAuth.Load().(string); a != "Bearer ollama" {
|
||||
t.Fatalf("Authorization=%q, want placeholder Bearer ollama", a)
|
||||
}
|
||||
if m, _ := gotModel.Load().(string); m != "llama3.2" {
|
||||
t.Fatalf("model on wire=%q", m)
|
||||
}
|
||||
}
|
||||
|
||||
// A keyless Ollama profile must be usable end to end: stored round-trips,
|
||||
// loadProfileConfig accepts it, the task chain validates it, the failover pool
|
||||
// includes it, and the connection test completes against a fake Ollama.
|
||||
func TestOllamaKeylessProfileLifecycle(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"1","object":"chat.completion","model":"llama3.2","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
pid, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: "ollama-local", Format: "ollama", Model: "llama3.2",
|
||||
BaseURL: fake.URL, APIKey: "", IsDefault: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
|
||||
|
||||
stored, err := m.pg.ProfileByID(pid)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("ProfileByID: %v %v", stored, err)
|
||||
}
|
||||
|
||||
if _, ok := s.loadProfileConfig(pid); !ok {
|
||||
t.Fatal("keyless ollama profile must load")
|
||||
}
|
||||
if err := s.validateTaskProfileIDs([]int64{pid}); err != nil {
|
||||
t.Fatalf("task chain must accept keyless ollama: %v", err)
|
||||
}
|
||||
pool, err := m.pg.PoolProfiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range pool {
|
||||
if p.ID == pid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("keyless ollama profile must join the failover pool")
|
||||
}
|
||||
|
||||
// Connection test: no api_key in the form either.
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"profile_id": pid, "provider": "ollama", "model": "llama3.2",
|
||||
"base_url": fake.URL, "api_key": "", "streaming": false,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.testLLM(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("handler status %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.OK {
|
||||
t.Fatalf("connection test on keyless ollama failed: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Model listing for an Ollama server works without an API key and tolerates a
|
||||
// base URL with or without the /v1 suffix.
|
||||
func TestOllamaListModels(t *testing.T) {
|
||||
var calls atomic.Value // []string paths
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
paths, _ := calls.Load().([]string)
|
||||
calls.Store(append(paths, r.URL.Path))
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"llama3.2"},{"id":"qwen2.5:14b"}]}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
|
||||
call := func(baseURL string) (bool, []string) {
|
||||
body, _ := json.Marshal(map[string]any{"provider": "ollama", "base_url": baseURL, "api_key": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/models", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.pgListModels(rec, req)
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Models []string `json:"models"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
return resp.OK, resp.Models
|
||||
}
|
||||
// Base WITH /v1.
|
||||
ok, models := call(fake.URL + "/v1")
|
||||
if !ok || len(models) != 2 {
|
||||
t.Fatalf("listing with /v1 base failed: ok=%v models=%v", ok, models)
|
||||
}
|
||||
// Base WITHOUT /v1 must still be normalized onto the OpenAI-compatible path
|
||||
// (/v1/models); both calls hit the same endpoint.
|
||||
ok2, models2 := call(fake.URL)
|
||||
if !ok2 || len(models2) != 2 {
|
||||
t.Fatalf("listing without /v1 base failed: ok=%v models=%v", ok2, models2)
|
||||
}
|
||||
if paths, _ := calls.Load().([]string); len(paths) != 2 || paths[0] != "/v1/models" || paths[1] != "/v1/models" {
|
||||
t.Fatalf("both calls must hit /v1/models, got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// The environment path must normalize the base URL too: ARTEX_LLM_BASE_URL is
|
||||
// the natural way to configure a LAN Ollama server, and it used to send chat to
|
||||
// /chat/completions instead of /v1/chat/completions.
|
||||
func TestOllamaFromEnvNormalizesBaseURL(t *testing.T) {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", "ollama")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434")
|
||||
c, ok := agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable")
|
||||
}
|
||||
if c.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("env base URL not normalized: %q", c.BaseURL)
|
||||
}
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "http://box:11434/v1/chat/completions")
|
||||
c, ok = agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable")
|
||||
}
|
||||
if c.BaseURL != "http://box:11434/v1" {
|
||||
t.Fatalf("full endpoint URL not normalized: %q", c.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// An Ollama config resolved from the environment must not inherit OPENAI_API_KEY:
|
||||
// Ollama ignores the bearer token, so inheritance would only ship the cloud key
|
||||
// to the local or LAN endpoint.
|
||||
func TestOllamaFromEnvDoesNotInheritOpenAIKey(t *testing.T) {
|
||||
t.Setenv("ARTEX_LLM_PROVIDER", "ollama")
|
||||
t.Setenv("OPENAI_API_KEY", "sk-cloud-secret")
|
||||
t.Setenv("ARTEX_LLM_BASE_URL", "")
|
||||
c, ok := agent.FromEnv()
|
||||
if !ok {
|
||||
t.Fatal("ollama env config must be usable without a key")
|
||||
}
|
||||
if c.APIKey != "" {
|
||||
t.Fatalf("ollama inherited a cloud key: %q", c.APIKey)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +293,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
var pin *int64
|
||||
if id := parseProfileID(a.LLMProfileID); id > 0 {
|
||||
if _, ok := s.loadProfileConfig(id); !ok {
|
||||
return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or has no API key set", id)), nil
|
||||
return actool.Errorf(fmt.Sprintf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id)), nil
|
||||
}
|
||||
pin = &id
|
||||
} else if a.ParentRef != "" {
|
||||
|
||||
+79
-29
@@ -46,6 +46,11 @@ type Server struct {
|
||||
skillDir string // root directory for skill subdirectories
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||||
|
||||
scrubSecrets bool // drop ARTEX_PG_DSN and the API keys from the environment once read (binary only)
|
||||
|
||||
setupMu sync.Mutex
|
||||
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
|
||||
|
||||
// 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.
|
||||
@@ -140,7 +145,7 @@ type triggeredRun struct {
|
||||
mergeable bool // true for finding/goal event triggers (merge by taskID)
|
||||
}
|
||||
|
||||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string) *Server {
|
||||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string, opts ...Option) *Server {
|
||||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("[auth] JWT key: %v", err)
|
||||
@@ -151,6 +156,10 @@ 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,
|
||||
// and again on every policy save (saveLLMRetryPolicy).
|
||||
@@ -232,9 +241,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
// Vulnerability IM push delivery engine. Sits beside the Scheduler but independent: push latency demands (3s)
|
||||
// differ from the triggers' business cadence, and neither's failures drag the other down — a stuck push must not affect agent triggers.
|
||||
go newNotifier(s).Run(s.ctx)
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
logSink.SetDB(ctx, m.pg) // restore last 100 log rows and enable async persistence
|
||||
}
|
||||
// precedence: persisted DB config > env.
|
||||
@@ -253,6 +259,16 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
} else {
|
||||
log.Printf("[engine] no LLM provider configured — engine idle until set via /api/llm or env")
|
||||
}
|
||||
// Both env readers have now run (NewManager for the DSN, FromEnv above for the
|
||||
// keys). Drop the secrets before anything below can spawn a child process:
|
||||
// restoring runtimes resumes workers that run shells, and MCP discovery starts
|
||||
// stdio servers.
|
||||
if s.scrubSecrets {
|
||||
ScrubSecretEnv()
|
||||
}
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
s.restoreTaskRuntimes()
|
||||
go s.reconcileConcurrency()
|
||||
s.startTaskArchiveWorker()
|
||||
@@ -335,7 +351,8 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
if cfg.APIKey == "" {
|
||||
// Ollama profiles run keyless (local server); the other formats need a key.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
}
|
||||
s.cfgMu.Lock()
|
||||
@@ -491,7 +508,8 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
}
|
||||
|
||||
// loadProfileConfig builds an agent.Config from a specific profile id (with its key).
|
||||
// ok=false when the profile is missing or has no api key.
|
||||
// ok=false when the profile is missing or unusable (no api key for the key-requiring
|
||||
// formats).
|
||||
func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
p, err := s.m.pg.ProfileByID(id)
|
||||
if err != nil || p == nil {
|
||||
@@ -506,7 +524,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
}
|
||||
return cfg, true
|
||||
@@ -1343,12 +1361,13 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg.ContextWindowK = k
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
// Keyless formats (Ollama) need no key; the others keep/require one.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
s.cfgMu.Lock()
|
||||
cfg.APIKey = s.llmCfg.APIKey // keep existing key if not re-entered
|
||||
s.cfgMu.Unlock()
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
writeErr(w, 400, "api_key required")
|
||||
return
|
||||
}
|
||||
@@ -1441,29 +1460,50 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
// see TestConnection). Endpoints like opencode zen that mandate x-opencode-session return
|
||||
// 400 without it, so the test path must send it too — otherwise "chat works, test 400s".
|
||||
cfg.SessionHeaderKey = req.SessionHeaderKey
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// Same for the session header name: when the form omits it, the stored profile's value is the fallback.
|
||||
if stored != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
// The custom session header name follows that profile regardless of format:
|
||||
// when the form omits it, the stored profile's value is the fallback.
|
||||
if stored != nil && cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
// Keyless formats (Ollama) skip the key resolution entirely.
|
||||
if cfg.NeedsAPIKey() {
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// A saved key is only sent to the endpoint it was saved for: base_url and
|
||||
// provider come from the form, so without this check the test button sends
|
||||
// the saved key to any host typed into the form.
|
||||
withheld := false
|
||||
if stored != nil && cfg.APIKey == "" {
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, stored.Format, stored.BaseURL) {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
s.cfgMu.Lock()
|
||||
global := s.llmCfg
|
||||
s.cfgMu.Unlock()
|
||||
if global.APIKey != "" {
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, global.Provider(), global.BaseURL) {
|
||||
cfg.APIKey = global.APIKey
|
||||
} else {
|
||||
withheld = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
if cfg.APIKey == "" {
|
||||
msg := "no API key provided"
|
||||
if withheld {
|
||||
msg = "no API key provided: the saved key is only sent to the endpoint it was saved for; enter the key to test a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
s.cfgMu.Lock()
|
||||
cfg.APIKey = s.llmCfg.APIKey
|
||||
s.cfgMu.Unlock()
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API key provided"})
|
||||
return
|
||||
}
|
||||
// Retry params do [not] go into the connection test: the test has a 30s hard timeout, and stacking the configured retry counts/long intervals on top
|
||||
// would only turn a perfectly usable endpoint into a "timeout failure". The test asks "does this endpoint work at all"; retry pacing
|
||||
// is a runtime concern.
|
||||
@@ -1478,6 +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.
|
||||
@@ -1621,7 +1671,7 @@ func (s *Server) validateTaskProfileIDs(ids []int64) error {
|
||||
}
|
||||
seen[id] = true
|
||||
if _, ok := s.loadProfileConfig(id); !ok {
|
||||
return fmt.Errorf("LLM profile #%d not found or has no API key set", id)
|
||||
return fmt.Errorf("LLM profile #%d not found or not usable (no API key for the key-requiring formats)", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+35
-7
@@ -2037,12 +2037,13 @@ func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// pgListModels fetches available models from the provider's API endpoint.
|
||||
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
|
||||
// Supports OpenAI-format (GET /models), Anthropic-format (GET /v1/models) and
|
||||
// Ollama (keyless OpenAI-compatible GET /v1/models on the local server); for
|
||||
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on
|
||||
// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root.
|
||||
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"` // "openai" | "anthropic"
|
||||
Provider string `json:"provider"` // "openai" | "anthropic" | "ollama"
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy"`
|
||||
@@ -2052,20 +2053,31 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// Resolve API key: form input > profile stored key.
|
||||
// Resolve API key: form input > profile stored key. The stored key is only
|
||||
// sent to the endpoint it was saved for, since base_url comes from the form.
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
withheld := false
|
||||
if apiKey == "" && req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
apiKey = p.APIKey
|
||||
if sameLLMEndpoint(req.Provider, req.BaseURL, p.Format, p.BaseURL) {
|
||||
apiKey = p.APIKey
|
||||
} else {
|
||||
withheld = p.APIKey != ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "no API Key provided"})
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
// Ollama serves its model list keyless on the local server: no key gate.
|
||||
if apiKey == "" && provider != "ollama" {
|
||||
msg := "no API Key provided"
|
||||
if withheld {
|
||||
msg = "no API Key provided: the saved key is only sent to the endpoint it was saved for; enter the key to list models from a different base URL or provider"
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
|
||||
// Candidate endpoints to try in order. Some Anthropic-compatible providers
|
||||
// (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose
|
||||
@@ -2097,6 +2109,22 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
|
||||
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
|
||||
case "ollama":
|
||||
// OpenAI-compatible list at {base}/v1/models. Tolerate base URLs with or
|
||||
// without the /v1 suffix (and a full endpoint URL). No auth needed; when a
|
||||
// key was provided anyway, send it as Bearer (Ollama ignores it).
|
||||
if baseURL == "" {
|
||||
baseURL = agent.DefaultOllamaBaseURL
|
||||
}
|
||||
b := strings.TrimSuffix(baseURL, "/chat/completions")
|
||||
if !strings.HasSuffix(b, "/v1") {
|
||||
b += "/v1"
|
||||
}
|
||||
hdr := http.Header{}
|
||||
if apiKey != "" {
|
||||
hdr.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
candidates = append(candidates, candidate{b + "/models", hdr})
|
||||
default: // anthropic
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
|
||||
@@ -29,7 +29,8 @@ func (s *Server) resolutionFromProfile(p *db.LLMProfile, source string) taskLLMR
|
||||
Model: p.Model,
|
||||
Source: source,
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
// Ollama profiles are keyless by design; the other formats need a key.
|
||||
if p.APIKey == "" && p.Format != "ollama" {
|
||||
result.Reason = "LLM profile has no API Key set"
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# ARTEX update script: (1) Docker update (pull new image and rebuild) (2) local build update (rebuild the binary)
|
||||
# ARTEX update script: (1) Docker update (rebuild the image from this checkout) (2) local build update (rebuild the binary)
|
||||
# Counterpart of install.sh: install handles the first-time setup, update upgrades to a new version.
|
||||
# No manual DB migration needed — artex idempotently re-runs schema.sql on every start (with ADD COLUMN/CREATE
|
||||
# INDEX IF NOT EXISTS), so "restart equals migrate". Data (pgdata volume, ./data, ./skills) is not affected.
|
||||
@@ -17,7 +17,16 @@ sync_repo(){
|
||||
[ -d .git ] && command -v git >/dev/null 2>&1 || { warn "not a git working copy, skipping git pull"; return; }
|
||||
[ "$(ask 'Pull the latest code (git pull --ff-only)? (y/n)' y)" = y ] || return
|
||||
if ! git pull --ff-only; then
|
||||
warn "git pull could not fast-forward (local changes or diverged branches) — resolve manually and retry; continuing with the current code"
|
||||
# Continuing would rebuild the OLD tree and still print "Build complete":
|
||||
# exactly the silent stale upgrade this script exists to prevent.
|
||||
cat >&2 <<'RECOVER'
|
||||
[!] git pull could not fast-forward. This happens when the checkout has local
|
||||
changes, or when it was cloned before the history rewrite of 2026-10-23.
|
||||
Refusing to rebuild stale code. Recover, then rerun this script:
|
||||
keep your changes: git stash && git fetch origin && git reset --hard origin/main && git stash pop
|
||||
discard them: git fetch origin && git reset --hard origin/main
|
||||
RECOVER
|
||||
die "aborting: nothing was rebuilt"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -27,23 +36,12 @@ update_docker(){
|
||||
|| die "docker / docker compose not detected; install and deploy with ./install.sh first"
|
||||
[ -f .env ] || die ".env not found; run ./install.sh first to complete the initial deployment"
|
||||
|
||||
# Optional: upgrade to a specific version tag (empty = keep ARTEX_TAG from .env, default latest)
|
||||
local tag; tag="$(ask 'Target image tag (press Enter to keep .env / latest)' '')"
|
||||
if [ -n "$tag" ]; then
|
||||
if grep -q '^ARTEX_TAG=' .env; then
|
||||
sed -i.bak "s|^ARTEX_TAG=.*|ARTEX_TAG=${tag}|" .env && rm -f .env.bak
|
||||
else
|
||||
printf '\nARTEX_TAG=%s\n' "$tag" >> .env
|
||||
fi
|
||||
ok "ARTEX_TAG set to ${tag}"
|
||||
fi
|
||||
|
||||
# Touch only artex: postgres is pinned to 16-alpine and does not need upgrading along
|
||||
# (pulling it wastes bandwidth, and a major version bump risks compatibility). artex
|
||||
# declares depends_on postgres, so an up with the service name starts pg if needed and leaves a running one untouched.
|
||||
info "Pulling new image (artex only)…"
|
||||
docker compose pull artex
|
||||
info "Rebuilding and starting (schema migrates automatically on artex restart)…"
|
||||
info "Building the image from this checkout (artex only)…"
|
||||
docker compose build artex
|
||||
info "Recreating artex (schema migrates automatically on restart)…"
|
||||
docker compose up -d artex
|
||||
ok "Update complete → http://localhost:8787"
|
||||
info "View logs: docker compose logs -f artex"
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [setupToken, setSetupToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
@@ -47,7 +48,7 @@ export default function SetupPage() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.initPassword(password);
|
||||
const { token } = await api.initPassword(password, setupToken.trim());
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
} catch (err) {
|
||||
@@ -80,7 +81,7 @@ export default function SetupPage() {
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||
{unavailable
|
||||
? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again."
|
||||
: "First time using ARTEX? Set a login password for the account (at least 8 characters)"}
|
||||
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 8 characters)"}
|
||||
</p>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
@@ -92,6 +93,17 @@ export default function SetupPage() {
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="setup-token">Setup token</Label>
|
||||
<Input
|
||||
id="setup-token"
|
||||
value={setupToken}
|
||||
onChange={(e) => setSetupToken(e.target.value)}
|
||||
placeholder="Printed in the server log at startup"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<Input
|
||||
@@ -100,7 +112,6 @@ export default function SetupPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,7 +127,11 @@ export default function SetupPage() {
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !setupToken.trim() || !password || !confirm}
|
||||
>
|
||||
{loading ? "Saving..." : "Set password and sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -49,13 +49,18 @@ const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "max_tokens (default)" },
|
||||
{ value: "max_completion_tokens", label: "max_completion_tokens" },
|
||||
];
|
||||
// The other two formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out.
|
||||
// The other formats hard-code their field names, so the option is meaningless for them; the hint texts spell this out.
|
||||
const MAX_TOKENS_FIELD_HINTS: Record<string, string> = {
|
||||
openai:
|
||||
"Which key carries the cap. max_tokens is the default and the vast majority of compatible gateways only accept it; OpenAI's official reasoning models (the o series / GPT-5) conversely only accept max_completion_tokens and fail outright with unsupported_parameter when they receive max_tokens.",
|
||||
anthropic: "Only selectable for the openai format. Anthropic's field name is fixed as max_tokens.",
|
||||
"openai-responses": "Only selectable for the openai format. The Responses API's field name is fixed as max_output_tokens.",
|
||||
ollama: "Only selectable for the openai format. Ollama's field name is fixed as max_tokens.",
|
||||
};
|
||||
// LLM profile formats. The default in the create form is "anthropic"; "ollama" is the
|
||||
// keyless local-server format (base URL defaults to http://127.0.0.1:11434/v1).
|
||||
type ProfileFormat = "anthropic" | "openai" | "openai-responses" | "ollama";
|
||||
const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1";
|
||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "Not sent (default)" },
|
||||
{ value: "low", label: "low" },
|
||||
@@ -72,10 +77,11 @@ function cooldownText(secs: number) {
|
||||
}
|
||||
|
||||
// The "healthy or not" badge a profile shows on its card. A profile without a Key cannot send any request at all — say that before the breaker;
|
||||
// the other states come from the pool's breaker records (with polling off no new records appear, so "healthy" = no known failure).
|
||||
// the exception is Ollama, a keyless local server that IS callable without a key. The other states come from the pool's breaker
|
||||
// records (with polling off no new records appear, so "healthy" = no known failure).
|
||||
type Health = { label: string; cls: string; hint?: string };
|
||||
function healthOf(p: LLMProfile, m?: LLMPoolMember): Health {
|
||||
if (!p.api_key_hint) {
|
||||
if (!p.api_key_hint && p.format !== "ollama") {
|
||||
return {
|
||||
label: "No Key configured",
|
||||
cls: "border-muted-foreground/40 text-muted-foreground",
|
||||
@@ -215,7 +221,8 @@ function PoolSheet({
|
||||
</div>
|
||||
{inChain.length < 2 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 profiles with an API Key set and pool participation are required.
|
||||
Only {inChain.length} usable profile(s) right now, so the pool has no effect — at least 2 usable profiles (API Key set, or Ollama) with pool participation are
|
||||
required.
|
||||
</p>
|
||||
)}
|
||||
{chain.map((m) => {
|
||||
@@ -311,7 +318,7 @@ function ProfileSheet({
|
||||
}) {
|
||||
const isNew = !profile;
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
|
||||
const [format, setFormat] = React.useState<ProfileFormat>("anthropic");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [proxy, setProxy] = React.useState("");
|
||||
@@ -340,7 +347,11 @@ function ProfileSheet({
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile?.name ?? "");
|
||||
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
|
||||
setFormat(
|
||||
profile?.format === "openai" || profile?.format === "openai-responses" || profile?.format === "ollama"
|
||||
? profile.format
|
||||
: "anthropic",
|
||||
);
|
||||
setModel(profile?.model ?? "");
|
||||
setBaseUrl(profile?.base_url ?? "");
|
||||
setProxy(profile?.proxy ?? "");
|
||||
@@ -457,6 +468,13 @@ function ProfileSheet({
|
||||
}
|
||||
}
|
||||
|
||||
let keyPlaceholder = "sk-…";
|
||||
if (format === "ollama") {
|
||||
keyPlaceholder = "optional — a local Ollama server runs keyless";
|
||||
} else if (keyHint) {
|
||||
keyPlaceholder = `Set (${keyHint}); leave empty to keep it`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
@@ -492,7 +510,7 @@ function ProfileSheet({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Format</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ProfileFormat)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a format" />
|
||||
</SelectTrigger>
|
||||
@@ -500,6 +518,7 @@ function ProfileSheet({
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
|
||||
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (local, no key needed)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -511,7 +530,7 @@ function ProfileSheet({
|
||||
<Input
|
||||
id="p-model"
|
||||
className="font-mono"
|
||||
placeholder="claude-opus-4-8"
|
||||
placeholder={format === "ollama" ? "llama3.2" : "claude-opus-4-8"}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
@@ -557,7 +576,7 @@ function ProfileSheet({
|
||||
<Input
|
||||
id="p-base-url"
|
||||
className="font-mono"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
placeholder={format === "ollama" ? OLLAMA_DEFAULT_BASE_URL : "https://api.openai.com/v1"}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
@@ -595,11 +614,13 @@ function ProfileSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">API Key</Label>
|
||||
<Label htmlFor="p-api-key">
|
||||
{format === "ollama" ? "API Key (optional for Ollama)" : "API Key"}
|
||||
</Label>
|
||||
<Input
|
||||
id="p-api-key"
|
||||
type="password"
|
||||
placeholder={keyHint ? `Set (${keyHint}); leave empty to keep it` : "sk-…"}
|
||||
placeholder={keyPlaceholder}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -240,7 +240,8 @@ export const api = {
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
|
||||
initPassword: (password: string, setupToken: string) =>
|
||||
post<{ token: string }>("/auth/init", { password, setup_token: setupToken }),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export interface NotificationDelivery {
|
||||
export interface LLMProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
format: "openai" | "anthropic" | "openai-responses";
|
||||
format: "openai" | "anthropic" | "openai-responses" | "ollama";
|
||||
base_url?: string;
|
||||
proxy?: string;
|
||||
model: string;
|
||||
|
||||
Reference in New Issue
Block a user