diff --git a/.env.example b/.env.example index e181950..0e8f085 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ -# Access token for the web UI (required). Generate one with: openssl rand -hex 32 -CODECRAFT_TOKEN= +# Optional: create the first admin on first start. Otherwise the server logs a +# one-time setup code and you create the admin in the web UI. +CODECRAFT_ADMIN_USER= +CODECRAFT_ADMIN_PASSWORD= + +# Single sign-on client secret (see [auth.oidc] in config.toml). +OIDC_CLIENT_SECRET= # Directory with your projects, mounted as /workspace. -WORKSPACE=/home/zink/Projects +WORKSPACE=/path/to/your/projects -# Host port. +# Host port and address (127.0.0.1 = only reachable through a local reverse proxy). PORT=8787 +BIND_ADDR=127.0.0.1 # UID/GID that owns the project files (id -u / id -g). UID=1000 diff --git a/.gitignore b/.gitignore index dc2dddf..566295e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ *.db *.db-wal *.db-shm +docker-compose.override.yml diff --git a/README.md b/README.md index 67f3952..5f36564 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,226 @@ -# CodeCraft +

+ CodeCraft icon: angle brackets with a slash on a dark tile +

-A self-hosted, web-based coding harness for AI models – in the spirit of Claude Code and -opencode, usable from any browser and installable as a PWA. +

CodeCraft

-- **Any model**: Anthropic (native Messages API), Google Gemini, opencode Go, fal.ai, - OpenRouter and self-hosted models (Ollama, vLLM, llama.cpp, LM Studio) through - OpenAI-compatible endpoints. -- **Agent loop with tools**: read, write, edit, list, glob, grep, bash, web_fetch, - todo_write, ask_user, task (subagents), skill – plus every tool of your MCP servers. -- **Modes**: `build` (full access), `plan` (read-only, proposes a plan), `craft` - (study a target → build your own version → check visually → refine), and custom - agents. -- **Human in the loop**: permission requests (with diffs) and questions from agents *and* - MCP servers (elicitation) are forwarded to the UI; optional browser notifications. -- **MCP**: stdio and Streamable HTTP servers, configured in the UI or the config file. -- **Statistics**: every model request is recorded (tokens in/out, cache, time to first - token, prefill and output speed, cost per model price) – per chat and in a dashboard - with daily charts and a weekday × hour heatmap. -- **Claude Code compatible project files**: `AGENTS.md`, `CLAUDE.md`, - `.claude/{agents,commands,skills}` (and `.codecraft/…`). -- **Configuration** in a commented TOML file that the web UI edits in place (comments - are preserved). -- **i18n**: English and German; adding a language is one file. +

+ A self-hosted coding agent for your browser: any model, your tools, your rules.
+ Run Claude, Gemini, opencode Go, fal.ai or your own models as coding agents with files, shell, MCP and subagents,
+ approve what they do from any device, and see exactly what every request costs. +

-## Quick start (Docker) +

+ Rust backend + React PWA + Docker + Status: early alpha +

+ +

+ How it works · + Modes · + Models · + Users · + Statistics · + Get started · + Configuration · + Themes · + Development · + Status +

+ +
+ +

+ A CodeCraft session: the agent edits a file, a permission card shows the diff and asks before running a command, the task list shows what is left +
+ A session: the agent works through its task list, shows diffs, and asks before anything outside the rules. +

+ + + + + + + + +
+

🧠 Any model

+ Anthropic natively, plus everything that speaks the OpenAI API: Gemini, opencode Go, fal.ai, OpenRouter, Ollama, vLLM, llama.cpp. Mix them in one chat. +
+

🛠️ Real tools

+ Read, write, edit, glob, grep, shell, web fetch, task lists, subagents, skills – and every tool of your MCP servers. +
+

🙋 You decide

+ Permission requests with diffs, questions from agents and MCP servers, per-tool rules. On your phone, with notifications. +
+

📊 Every token counted

+ Tokens, cache hits, prefill and output speed, and cost per request – per chat and in a dashboard. +
+ +
+ +## How it works + +CodeCraft runs on your server, next to your projects. You talk to it from a browser or the +installed app; the agent keeps working when you close the tab. + +1. **Pick a folder and a mode.** Every user has their own folder; admins can share more. +2. **Say what you want.** The agent plans with a task list, reads the code and makes changes. +3. **Approve or answer.** Anything outside your rules shows up as a card: allow once, allow + for the session, or reject with feedback. Questions from agents and MCP servers too. +4. **Delegate.** The agent hands work to subagents – in parallel or in the background, on + other models and hosts – and continues while they run. + + + + + + +
+ CodeCraft on a phone: a session with a question card offering three options
+ Mobile: answer questions and approve changes from your phone. +
+ Settings: providers with models, context sizes and prices per million tokens
+ Settings: providers, models, prices, MCP, permissions and users – all in the UI. +
+ +## Modes + +| Mode | What it does | +|---|---| +| **build** | Full access: implement, edit, run. | +| **plan** | Read-only: investigate, ask, and propose a step-by-step plan. One click switches to build. | +| **craft** | Study a target, build your own version, check it visually, refine. Websites are captured at several screen widths in a headless browser and compared with the build (similarity per page and width). *Clean room* uses only what a visitor sees; *deep mode* may read the page source. | +| *custom* | Your own modes and subagents – in the UI, the config file or as Markdown in `.codecraft/agents` / `.claude/agents`. | + +Projects keep working the way they do in Claude Code and opencode: `AGENTS.md`, `CLAUDE.md`, +slash commands in `.claude/commands` and skills in `.claude/skills` are picked up automatically. + +## Models + +Every provider is an endpoint with a list of models. Each model can have a context size, +output limit, reasoning effort, extra request options and prices per million tokens. + +- **Several models per chat:** switch the model at any time, give modes a fixed model, and + run subagents on other models – e.g. a coding model on one GPU host and a small model that + writes documentation and translations in the background on another. +- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that + can only process one at a time; `max_parallel` limits instances of a subagent. Waiting time + is shown in the chat and never counted as model latency. +- **Background subagents:** the agent starts them with `background = true`, keeps working, + and receives their result as a message when they finish. + +## Users + +- Local accounts (Argon2 passwords) and single sign-on via OpenID Connect (Authentik, Authelia, + Keycloak, …) with optional admin and access groups. +- **Admins** manage providers, MCP, permissions, users and shared folders and see all statistics. + **Users** see their own sessions and statistics. +- Every user works in `/`; admins can share additional folders with + selected users. Sessions are private and can be shared read-only. + +> [!NOTE] +> File tools are confined to a user's folders. Shell commands run in the shared container and +> are **not** sandboxed per user – only give accounts to people you trust with that container. + +## Statistics + +

+ The statistics dashboard: cost and token tiles, cost per day stacked by model, tokens per day, and an activity heatmap by weekday and hour +

+ +Every model request is stored with input, output and cache tokens, time to first token, +prefill and output speed, and its cost at the price configured at that moment. The dashboard +shows totals, cost per day by model, tokens per day, a weekday × hour heatmap, and tables per +model, provider, mode, user and session – each chart also as a table. + +## Get started ```sh -cp .env.example .env # set CODECRAFT_TOKEN, WORKSPACE and provider keys +git clone codecraft && cd codecraft +cp .env.example .env # workspace path, provider keys, optional first admin docker compose up -d --build +docker compose logs codecraft # shows the one-time setup code for the first admin ``` -Open `http://:8787`, sign in with the token and pick a project directory. On first -start the commented default config is written to `data/config.toml`. Put the service behind -a TLS reverse proxy for remote access – the PWA install prompt requires HTTPS. +All state – configuration, database, the agent's home directory – lives in the Docker volume +`codecraft-data`; rebuilding or recreating the container keeps it. Your projects are mounted +from `WORKSPACE`. + +The port is bound to `127.0.0.1` by default. Put a TLS reverse proxy in front of it (enable +WebSocket support) and set `auth.public_url` – installing the PWA requires HTTPS. ## Configuration -See [`config.example.toml`](config.example.toml) – every option is documented there. -Highlights: +One commented TOML file, `/data/config.toml` in the volume, created from +[`config.example.toml`](config.example.toml) on first start. The settings page edits it in +place and keeps your comments. Secrets belong in the environment and are referenced as +`${NAME}`. -- `[providers.]` – `kind = "anthropic" | "openai"`, `base_url`, `api_key` - (`${ENV}` references recommended), optional `models` with context size, effort and - prices per 1M tokens. Without `models`, the list is fetched from the provider. -- `[mcp.]` – `type = "stdio"` (command/args/env) or `"http"` (url/headers). -- `[permissions]` – `allow` / `ask` / `deny` per tool (globs such as `mcp__*`), plus - `[permissions.bash_rules]` for shell command patterns. Compound commands are only - auto-allowed when every part is allowed. -- `[agents.]` – custom modes and subagents. -- `[craft]` – Chromium binary, screenshot widths, clean room vs. deep mode. +| Section | Purpose | +|---|---| +| `[server]` | workspace, default and small model, currency, auto-compaction, subagent limit | +| `[providers.]` | `kind = "anthropic" \| "openai"`, `base_url`, `api_key`, `max_concurrency`, `models` | +| `[mcp.]` | `type = "stdio"` (command, args, env) or `"http"` (url, headers) | +| `[permissions]` | `allow` / `ask` / `deny` per tool (globs like `mcp__*`); `bash_rules` for commands | +| `[agents.]` | modes and subagents: model, tools, `max_parallel`, prompt | +| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on | +| `[craft]` | headless browser, screen widths, clean room | + +Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed. + +## Themes + +Colours, syntax highlighting and chart palettes come from CSS variables. Each theme is one +file in [`web/src/themes`](web/src/themes) (Gruvbox Dark and Light ship by default): + +1. Copy `gruvbox-dark.css` to `.css`, change the values on `:root[data-theme=""]`. +2. Import it in `themes/index.ts` and add it to `THEMES` in `web/src/theme.ts`. + +The variables cover surfaces (`--bg0`–`--bg3`), text (`--fg0`–`--fg3`, `--muted`), accents, +diffs, syntax (`--syntax-*`), charts (`--series-1`–`8` categorical, `--seq-0`–`6` sequential) +and the browser bar colour (`--theme-color`). The chart colours are validated for colour-vision +deficiencies against the background – re-check them when the background changes a lot. ## Development -Requirements: Rust (edition 2024), Node ≥ 20.19. +Requirements: Rust (edition 2024) and Node ≥ 20.19. ```sh -# backend (writes ./data/config.toml on first start) +# backend – writes ./data/config.toml on first start cd server && CODECRAFT_CONFIG=../data/config.toml cargo run # frontend with hot reload, proxies /api to 127.0.0.1:8787 cd web && npm install && npm run dev ``` -For local development set `bind = "127.0.0.1:8787"` and `workspace` to your projects -directory in `data/config.toml`. - -Tests: `cd server && cargo test`, type check: `cd web && npx tsc --noEmit`. - -## Architecture +Tests: `cd server && cargo test` · type check: `cd web && npx tsc --noEmit`. ``` -server/ (Rust, axum + tokio) - provider/ Anthropic + OpenAI-compatible streaming clients - runner.rs agent loop: streaming, tool execution, permissions, subagents, compaction - tools/ built-in tools (fs, bash, web, agent, craft) - mcp.rs MCP client (stdio, Streamable HTTP, elicitation) - prompts.rs agents/modes, system prompt, AGENTS.md, slash commands, skills - permission.rs, config.rs (toml + toml_edit), store.rs (SQLite), api.rs (REST + WebSocket) -web/ (React + Vite, PWA) - src/components chat, composer, permission/question cards, settings, statistics - src/i18n en (reference), de +server/ Rust (axum, tokio) + runner.rs agent loop: streaming, tools, permissions, subagents, compaction + provider/ Anthropic and OpenAI-compatible streaming clients, concurrency slots + tools/ built-in tools (files, shell, web, agent, craft) + mcp.rs MCP client (stdio, Streamable HTTP, elicitation) + auth.rs users, sessions, OpenID Connect + prompts.rs modes, system prompt, AGENTS.md, commands, skills +web/ React + Vite PWA + src/components chat, composer, cards, settings, statistics + src/themes CSS themes + src/i18n English (reference) and German ``` -The server streams all events over one WebSocket (`/api/ws`); the UI reconnects and -resyncs automatically, so long-running agents keep going when the browser is closed. +Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in +`web/src/i18n/index.ts` – the type checker reports missing keys. -## Roadmap +## Status -- Background shell processes, file checkpoints/undo, `@file` mentions -- MCP OAuth, resources and prompts; remote MCP over SSE (legacy) -- Hooks (pre/post tool), output styles, per-project config overrides -- Image generation tools (fal.ai), OpenAI Responses API -- Multi-user accounts +Early alpha. Planned next: + +- Per-user sandboxes for shell commands +- File checkpoints and undo, background shells, `@file` mentions +- MCP OAuth, resources and prompts +- Hooks before/after tool calls +- Image generation tools (fal.ai) and the OpenAI Responses API diff --git a/config.example.toml b/config.example.toml index cb10f18..2c9a80b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,10 +24,6 @@ workspace = "/workspace" # Where sessions (SQLite) and runtime state are stored. data_dir = "/data" -# Access token for the web UI and API. Leave empty to disable auth (only do -# this on localhost!). Recommended: auth_token = "${CODECRAFT_TOKEN}" -auth_token = "${CODECRAFT_TOKEN}" - # Default model for new sessions, as "/". default_model = "anthropic/claude-sonnet-5-5" @@ -37,6 +33,14 @@ small_model = "anthropic/claude-haiku-5-5" # Compact the conversation automatically when the context is this full (0.0–1.0). auto_compact_threshold = 0.85 +# Max. subagents running at the same time (all sessions). Additionally, each +# subagent can be limited with max_parallel, and each provider/model with +# max_concurrency (see below). +max_parallel_subagents = 4 + +# MCP servers with more tools than this are loaded on demand (see [mcp.*] load). +mcp_direct_limit = 15 + # Currency label for the cost statistics (prices below are in this currency). currency = "USD" @@ -57,6 +61,13 @@ currency = "USD" # Cache prices default to input_price when omitted. Costs are stored per request, # so changing a price only affects future requests. # +# Concurrency: max_concurrency limits simultaneous requests – on the provider +# (= the host) and/or per model. Requests beyond the limit wait in a queue, +# e.g. for self-hosted models that can only process one request at a time. +# +# options: extra request body fields sent as-is, e.g. +# options = { temperature = 0.5, reasoning_budget_tokens = 5000 } +# # Authentication: by default "Authorization: Bearer " (openai) or # "x-api-key: " (anthropic). Override with auth_header / auth_prefix. # Extra static headers: headers = { "X-Foo" = "bar" } @@ -112,6 +123,7 @@ enabled = false kind = "openai" base_url = "http://host.docker.internal:11434/v1" api_key = "" +max_concurrency = 1 # one request at a time on this host models = [] enabled = false @@ -122,6 +134,10 @@ enabled = false # type = "stdio" → command + args (+ env), started as a child process # type = "http" → Streamable HTTP endpoint (url + optional headers) # Tools appear to the model as mcp____. +# load = "auto" (default) | "always" | "on_demand": servers with many tools +# (more than server.mcp_direct_limit) are not sent with every request; the +# model finds them with mcp_search and calls them with mcp_call. This keeps +# requests small – important for self-hosted models. # MCP servers may ask you questions (elicitation); they show up in the UI. # ----------------------------------------------------------------------------- @@ -163,6 +179,7 @@ bash = "ask" web_fetch = "ask" browser_snapshot = "allow" image_compare = "allow" +generate_image = "ask" # costs money per image "mcp__*" = "ask" # Shell command patterns (glob, matched against the full command). First match wins. @@ -185,14 +202,85 @@ image_compare = "allow" # mode switcher, mode = "subagent" is only available through the task tool. # ----------------------------------------------------------------------------- -# [agents.reviewer] +# Subagents can run on a different model (and host) than the chat, and in the +# background: the main agent calls task(background = true), keeps working and +# receives the result as a message when the subagent is done. + +# [agents.writer] # mode = "subagent" -# description = "Reviews code changes for bugs and style issues." -# model = "anthropic/claude-opus-5-5" # optional, otherwise session model -# tools = ["read", "glob", "grep", "bash"] # optional, default: all tools -# prompt = """ -# You are a meticulous code reviewer. ... -# """ +# description = "Writes documentation, summaries, guides and translations." +# model = "local/qwen3-4b" # optional, otherwise the session model +# tools = ["read", "glob", "grep"] # optional, default: all tools +# max_parallel = 1 # optional, instances at the same time +# prompt = ''' +# You are a technical writer. Write clear, well-structured text. +# ''' + + +# ----------------------------------------------------------------------------- +# Image generation (fal.ai) +# ----------------------------------------------------------------------------- +# Agents get the tool generate_image; the "Images" page creates images manually. +# Images are saved in the project (output_dir) and listed in a gallery. +# Model ids: https://fal.ai/models – price is per image (for the statistics). +# size_param: "image_size" (FLUX-style presets, default) or "aspect_ratio" ("16:9"). +# ----------------------------------------------------------------------------- + +[images] +enabled = false +base_url = "https://fal.run" +api_key = "${FAL_KEY}" +default_model = "fal-ai/flux/schnell" +output_dir = "generated" +models = [ + { id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 }, + { id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" }, + { id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" }, + { id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" }, +] + + +# ----------------------------------------------------------------------------- +# Users & sign-in +# ----------------------------------------------------------------------------- +# Users are managed in the web UI (Settings → Users). On first start without +# users, the server prints a one-time setup code to its log; use it in the UI +# to create the first admin – or set CODECRAFT_ADMIN_USER and +# CODECRAFT_ADMIN_PASSWORD in the environment. +# +# Every user works in their own directory /; admins can +# use the whole workspace. Note: file tools are confined to that directory, +# but shell commands run in the shared container and are not sandboxed. +# ----------------------------------------------------------------------------- + +[auth] +# Lifetime of a login in days. +session_days = 30 +# External URL of this server – required for single sign-on (OIDC redirects). +public_url = "" +# Secure cookies: "auto" (when served via https), "true" or "false". +cookie_secure = "auto" + +# Single sign-on via OpenID Connect (Authentik, Authelia, Keycloak, ...). +# Redirect URI to register at the IdP: /api/auth/oidc/callback +[auth.oidc] +enabled = false +issuer = "https://auth.example.org/application/o/codecraft/" +client_id = "codecraft" +client_secret = "${OIDC_CLIENT_SECRET}" +scopes = ["openid", "profile", "email", "groups"] +username_claim = "preferred_username" +groups_claim = "groups" +# Members of this group are admins; empty = manage roles in CodeCraft. +admin_group = "" +# Only members of this group may sign in; empty = no restriction. +allowed_group = "" +# Create users automatically on their first sign-in. +auto_create = true +# Link a sign-in to an existing local user with the same username. +# Only enable this if you trust the usernames of your IdP. +link_by_username = false +button_label = "Single sign-on" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 8b628ad..34b783f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,10 @@ # CodeCraft – copy .env.example to .env and adjust, then: docker compose up -d --build +# +# Persistent state lives in the named volume "codecraft-data" (mounted at /data): +# /data/config.toml configuration (also editable in the web UI) +# /data/codecraft.db sessions, messages, statistics +# /data/home home directory of the agent (git config, caches, ...) +# Rebuilding or recreating the container keeps all of it. services: codecraft: build: . @@ -7,21 +13,24 @@ services: restart: unless-stopped # Same UID/GID as the owner of your project files. user: "${UID:-1000}:${GID:-1000}" + # Bound to localhost by default: expose it through a TLS reverse proxy. + # Set BIND_ADDR=0.0.0.0 to publish it on all interfaces. ports: - - "${PORT:-8787}:8787" - environment: - CODECRAFT_TOKEN: ${CODECRAFT_TOKEN:?set CODECRAFT_TOKEN in .env} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} - GEMINI_API_KEY: ${GEMINI_API_KEY:-} - OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} - FAL_KEY: ${FAL_KEY:-} - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + - "${BIND_ADDR:-127.0.0.1}:${PORT:-8787}:8787" + # All variables from .env are available to the config as ${NAME} + # (provider keys, MCP tokens, OIDC secret, initial admin). + env_file: + - path: .env + required: false volumes: - # Config (config.toml), SQLite database, agent home directory. - - ./data:/data + - codecraft-data:/data # The projects the agents may work on. - ${WORKSPACE:-./workspace}:/workspace # Reach self-hosted models (Ollama, vLLM, ...) on the host. extra_hosts: - "host.docker.internal:host-gateway" shm_size: "1gb" # Chromium (craft mode) + +volumes: + codecraft-data: + name: codecraft-data diff --git a/docs/images/chat.png b/docs/images/chat.png new file mode 100644 index 0000000..62671df Binary files /dev/null and b/docs/images/chat.png differ diff --git a/docs/images/mobile.png b/docs/images/mobile.png new file mode 100644 index 0000000..9a17265 Binary files /dev/null and b/docs/images/mobile.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png new file mode 100644 index 0000000..3ce1ded Binary files /dev/null and b/docs/images/settings.png differ diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 0000000..ccb8645 Binary files /dev/null and b/docs/images/stats.png differ diff --git a/server/Cargo.lock b/server/Cargo.lock index 1e0f2bd..66c9d57 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -32,6 +32,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -157,12 +169,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -172,6 +199,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.13.1" @@ -264,11 +300,18 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codecraft" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "async-trait", "axum", "base64 0.22.1", @@ -281,11 +324,13 @@ dependencies = [ "ignore", "image", "libc", + "rand 0.10.3", "regex", "reqwest", "rusqlite", "serde", "serde_json", + "sha2", "shell-words", "similar", "tokio", @@ -308,6 +353,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.10.1" @@ -386,6 +437,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -398,8 +467,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -757,6 +838,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -1309,12 +1399,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "getrandom 0.4.3", + "phc", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.11.3" @@ -1635,6 +1746,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -1925,7 +2037,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index 01d5375..acd52a2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -2,11 +2,13 @@ name = "codecraft" version = "0.1.0" edition = "2024" -license = "MIT" description = "Web-based coding harness for AI models" [dependencies] anyhow = "1" +argon2 = "0.6" +sha2 = "0.11" +rand = "0.10" async-trait = "0.1" axum = { version = "0.8", features = ["ws", "macros"] } bytes = "1" @@ -21,7 +23,7 @@ shell-words = "1" libc = "0.2" ignore = "0.4" regex = "1" -reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] } rusqlite = { version = "0.41", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/server/src/api.rs b/server/src/api.rs index a545657..b2606e6 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -1,17 +1,20 @@ -//! HTTP + WebSocket API. +//! HTTP + WebSocket API. Every route except the public auth endpoints requires a +//! signed-in user; admin-only routes check the role. -use crate::app::{App, PermissionReply}; +use crate::app::{Access, App, PermissionReply}; +use crate::auth::{self, require_admin}; use crate::config::{self, ConfigPatch}; use crate::prompts; use crate::runner; +use crate::store::User; use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use axum::extract::{Path, Query, Request, State}; -use axum::http::{StatusCode, header}; -use axum::middleware::{self, Next}; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::middleware; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; +use axum::routing::{delete, get, post, put}; +use axum::{Extension, Json, Router}; use serde::Deserialize; use serde_json::{Map, Value, json}; use std::path::PathBuf; @@ -19,7 +22,7 @@ use std::sync::Arc; type AppState = Arc; -pub struct ApiError(StatusCode, String); +pub struct ApiError(pub StatusCode, pub String); impl IntoResponse for ApiError { fn into_response(self) -> Response { @@ -33,15 +36,33 @@ impl> From for ApiError { } } -type ApiResult> = Result; +pub type ApiResult> = Result; fn not_found(what: &str) -> ApiError { ApiError(StatusCode::NOT_FOUND, format!("{what} not found")) } +fn forbidden() -> ApiError { + ApiError(StatusCode::FORBIDDEN, "no access to this session".into()) +} + pub fn router(app: AppState) -> Router { let api = Router::new() - .route("/info", get(info)) + // public + .route("/info", get(auth::info)) + .route("/auth/login", post(auth::login)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/setup", post(auth::setup)) + .route("/auth/oidc/start", get(auth::oidc_start)) + .route("/auth/oidc/callback", get(auth::oidc_callback)) + // signed in + .route("/auth/password", post(auth::change_password)) + .route("/users", get(auth::list_users).post(auth::create_user)) + .route("/users/directory", get(auth::directory)) + .route("/users/{id}", axum::routing::patch(auth::update_user).delete(auth::delete_user)) + .route("/folders", get(list_folders).post(create_folder)) + .route("/folders/{id}", delete(delete_folder)) + .route("/folders/{id}/access", put(set_folder_access)) .route("/config", get(get_config).patch(patch_config)) .route("/config/raw", get(get_config_raw).put(put_config_raw)) .route("/providers/reload", post(reload_providers)) @@ -55,15 +76,38 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/rewind", post(rewind)) .route("/sessions/{id}/stats", get(session_stats)) + .route("/sessions/{id}/shares", get(get_shares).put(put_shares)) + .route("/sessions/{id}/queue", get(get_queue)) + .route("/sessions/{id}/queue/flush", post(flush_queue)) + .route("/sessions/{id}/queue/{item}", delete(delete_queue_item)) .route("/permissions/{id}", post(reply_permission)) .route("/questions/{id}", post(answer_question)) .route("/pending", get(pending)) .route("/mcp", get(mcp_status)) .route("/mcp/reload", post(mcp_reload)) + .route("/fs/roots", get(list_roots)) + .route("/files/list", get(crate::files::list)) + .route("/files/read", get(crate::files::read)) + .route("/files/raw", get(crate::files::raw)) + .route("/files/write", put(crate::files::write)) + .route("/files/create", post(crate::files::create)) + .route("/files/move", post(crate::files::move_or_copy)) + .route("/files/delete", delete(crate::files::remove)) + .route("/files/upload", put(crate::files::upload)) + .route("/files/search", get(crate::files::search)) + .route("/git", post(crate::git::handle)) .route("/fs/dirs", get(list_dirs)) .route("/stats", get(stats)) + .route("/budget", get(budget)) + .route("/providers/{name}/prices", get(provider_prices)) + .route("/images", get(list_images).post(create_images)) + .route("/images/config", get(images_config)) + .route("/images/{id}", delete(delete_image)) + .route("/images/{id}/file", get(image_file)) .route("/ws", get(ws)) - .layer(middleware::from_fn_with_state(app.clone(), auth)); + .layer(middleware::from_fn_with_state(app.clone(), auth::require_user)) + // Prompts may carry images (base64); the default limit is 2 MB. + .layer(axum::extract::DefaultBodyLimit::max(50 * 1024 * 1024)); let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| { ["../web/dist", "./web/dist", "/app/web"] @@ -73,64 +117,42 @@ pub fn router(app: AppState) -> Router { .unwrap_or_else(|| PathBuf::from("./web/dist")) }); let index = web_dir.join("index.html"); - let static_files = tower_http::services::ServeDir::new(&web_dir) - .fallback(tower_http::services::ServeFile::new(index)); + let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index)); Router::new().nest("/api", api).fallback_service(static_files).with_state(app) } -/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie. -async fn auth(State(app): State, req: Request, next: Next) -> Response { - let expected = app.config().auth_token(); - if expected.is_empty() || req.uri().path().ends_with("/info") { - return next.run(req).await; +/// Load a session the user may at least read. +fn readable(app: &App, user: &User, id: &str) -> Result { + let s = app.store.get_session(id)?.ok_or_else(|| not_found("session"))?; + if app.access(user, id) == Access::None { + return Err(forbidden()); } - let header_token = req - .headers() - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .map(String::from); - let query_token = req.uri().query().and_then(|q| { - q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from) - }); - let cookie_token = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from)); - let ok = [header_token, query_token, cookie_token] - .into_iter() - .flatten() - .any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes())); - if ok { - next.run(req).await - } else { - ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response() + Ok(s) +} + +/// Load a session the user may control (owner or admin). +fn writable(app: &App, user: &User, id: &str) -> Result { + let s = readable(app, user, id)?; + if app.access(user, id) != Access::Full { + return Err(ApiError(StatusCode::FORBIDDEN, "this session is shared with you read-only".into())); } + Ok(s) } -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 -} +// --- config (admin) ----------------------------------------------------------- -// --- info & config ---------------------------------------------------------- - -async fn info(State(app): State) -> Json { - let cfg = app.config(); - Json(json!({ - "name": "CodeCraft", - "version": env!("CARGO_PKG_VERSION"), - "auth_required": !cfg.auth_token().is_empty(), - })) -} - -async fn get_config(State(app): State) -> ApiResult { +async fn get_config(Extension(user): Extension, State(app): State) -> ApiResult { let cfg = app.config(); + let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }; + if !user.is_admin() { + // Users only need defaults, not the configuration. + return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency}))); + } Ok(Json(json!({ "config": config::redacted_json(&cfg), "workspace": app.workspace(), - "default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }, + "default_model": default_model, "currency": cfg.server.currency, }))) } @@ -140,14 +162,16 @@ struct PatchBody { patches: Vec, } -async fn patch_config(State(app): State, Json(body): Json) -> ApiResult { +async fn patch_config(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { + require_admin(&user)?; // Masked secrets coming back from the UI must not overwrite real values. let patches: Vec = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect(); app.patch_config(&patches).await?; Ok(Json(json!({"ok": true}))) } -async fn get_config_raw(State(app): State) -> ApiResult { +async fn get_config_raw(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; Ok(Json(json!({"text": app.config_text()?}))) } @@ -156,12 +180,14 @@ struct RawBody { text: String, } -async fn put_config_raw(State(app): State, Json(body): Json) -> ApiResult { +async fn put_config_raw(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { + require_admin(&user)?; app.save_config(&body.text).await?; Ok(Json(json!({"ok": true}))) } -async fn reload_providers(State(app): State) -> ApiResult { +async fn reload_providers(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; app.reload_providers().await; Ok(Json(app.registry().models_json())) } @@ -175,33 +201,92 @@ struct CwdQuery { cwd: Option, } -fn cwd_or_workspace(app: &App, cwd: Option) -> PathBuf { - cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace()) +/// Root and cwd for listing agents/commands; falls back to the user's first root. +fn scope(app: &App, user: &User, cwd: Option) -> (PathBuf, PathBuf) { + let owner = Some(user.id.as_str()); + let roots = app.user_roots(owner); + let cwd = cwd.map(PathBuf::from).filter(|c| roots.iter().any(|r| c.starts_with(r))).unwrap_or_else(|| roots[0].clone()); + (app.root_for(owner, &cwd), cwd) } -async fn agents(State(app): State, Query(q): Query) -> Json { - let cwd = cwd_or_workspace(&app, q.cwd); - Json(json!(prompts::agents(&app, &cwd))) +async fn agents(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { + let (root, cwd) = scope(&app, &user, q.cwd); + Json(json!(prompts::agents(&app, &root, &cwd))) } -async fn commands(State(app): State, Query(q): Query) -> Json { - let cwd = cwd_or_workspace(&app, q.cwd); +async fn commands(Extension(user): Extension, State(app): State, Query(q): Query) -> Json { + let (root, cwd) = scope(&app, &user, q.cwd); Json(json!({ - "commands": prompts::commands(&app, &cwd), - "skills": prompts::skills(&app, &cwd), + "commands": prompts::commands(&app, &root, &cwd), + "skills": prompts::skills(&app, &root, &cwd), })) } +// --- shared folders (admin) --------------------------------------------------- + +async fn list_folders(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + Ok(Json(json!({"workspace": app.workspace(), "folders": app.store.folders()?}))) +} + +#[derive(Deserialize)] +struct FolderBody { + /// Path relative to the workspace (or absolute inside it). Created if missing. + path: String, + #[serde(default)] + users: Vec, +} + +async fn create_folder(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { + require_admin(&user)?; + let ws = app.workspace(); + let rel = body.path.trim().trim_start_matches(ws.to_str().unwrap_or("")).trim_matches('/'); + if rel.is_empty() || rel.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c.starts_with('.')) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid folder path".into())); + } + let path = ws.join(rel); + std::fs::create_dir_all(&path)?; + let path = std::fs::canonicalize(&path)?; + if !path.starts_with(&ws) || path == ws { + return Err(ApiError(StatusCode::BAD_REQUEST, "folder must be inside the workspace".into())); + } + let id = app.store.add_folder(&path.display().to_string())?; + app.store.set_folder_access(&id, &body.users)?; + Ok(Json(json!({"id": id, "path": path}))) +} + +async fn delete_folder(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + require_admin(&user)?; + // Only the share is removed; the directory and its files stay. + app.store.delete_folder(&id)?; + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +struct AccessBody { + users: Vec, +} + +async fn set_folder_access(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + require_admin(&user)?; + app.store.set_folder_access(&id, &body.users)?; + Ok(Json(json!({"ok": true}))) +} + // --- sessions --------------------------------------------------------------- -async fn list_sessions(State(app): State) -> ApiResult { - let sessions = app.store.list_sessions()?; - let list: Vec = sessions +async fn list_sessions(Extension(user): Extension, State(app): State) -> ApiResult { + let filter = if user.is_admin() { None } else { Some(user.id.as_str()) }; + let list: Vec = app + .store + .list_sessions(filter)? .into_iter() .map(|s| { let busy = app.is_busy(&s.id); + let shared = s.owner_id.as_deref() != Some(user.id.as_str()); let mut v = json!(s); v["busy"] = json!(busy); + v["shared"] = json!(shared); v }) .collect(); @@ -216,29 +301,34 @@ struct CreateSession { permission_mode: Option, } -fn validate_cwd(app: &App, cwd: Option) -> Result { - let ws = app.workspace(); +/// Resolve a directory and check it lies inside one of the user's roots. +fn validate_cwd(app: &App, user: &User, cwd: Option) -> Result { + let roots = app.user_roots(Some(&user.id)); let p = match cwd.filter(|c| !c.trim().is_empty()) { Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c), - Some(c) => ws.join(c), - None => ws.clone(), + Some(c) => roots[0].join(c), + None => roots[0].clone(), }; let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?; - if !p.starts_with(&ws) || !p.is_dir() { - return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into())); + if !p.is_dir() || !roots.iter().any(|r| p.starts_with(r)) { + return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into())); } Ok(p) } -async fn create_session(State(app): State, Json(body): Json) -> ApiResult { - let cwd = validate_cwd(&app, body.cwd)?; +async fn create_session(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { + let cwd = validate_cwd(&app, &user, body.cwd)?; let cfg = app.config(); let model = body .model .filter(|m| !m.is_empty()) .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())) .or_else(|| app.registry().first_model()) - .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?; + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?; + let mode = body.permission_mode.unwrap_or_else(|| "auto".into()); + if !crate::permission::MODES.contains(&mode.as_str()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); + } let now = now_ms(); let s = Session { id: new_id(), @@ -246,8 +336,9 @@ async fn create_session(State(app): State, Json(body): Json, Json(body): Json, Path(id): Path) -> ApiResult { - let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; +async fn get_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let s = readable(&app, &user, &id)?; let messages = app.store.messages(&id)?; let busy = app.is_busy(&id); - Ok(Json(json!({"session": s, "messages": messages, "busy": busy}))) + let can_write = app.access(&user, &id) == Access::Full; + let queue = app.store.queue_list(&id)?; + Ok(Json(json!({"session": s, "messages": messages, "busy": busy, "can_write": can_write, "queue": queue}))) } #[derive(Deserialize)] @@ -272,8 +365,8 @@ struct UpdateSession { permission_mode: Option, } -async fn update_session(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { - let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; +async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let mut s = writable(&app, &user, &id)?; if let Some(t) = body.title { s.title = t; } @@ -285,7 +378,7 @@ async fn update_session(State(app): State, Path(id): Path, Jso s.agent = a; } if let Some(p) = body.permission_mode { - if !["default", "accept_edits", "bypass"].contains(&p.as_str()) { + if !crate::permission::MODES.contains(&p.as_str()) { return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); } s.permission_mode = p; @@ -293,10 +386,12 @@ async fn update_session(State(app): State, Path(id): Path, Jso Ok(Json(json!(app.save_session(s)?))) } -async fn delete_session(State(app): State, Path(id): Path) -> ApiResult { +async fn delete_session(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; + // Notify before deleting: visibility is checked against the stored session. + app.emit(Event::SessionDeleted { session_id: id.clone() }); runner::abort(&app, &id); app.store.delete_session(&id)?; - app.emit(Event::SessionDeleted { session_id: id }); Ok(Json(json!({"ok": true}))) } @@ -307,8 +402,8 @@ struct PromptBody { images: Vec, } -async fn prompt(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { - let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; +async fn prompt(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let session = writable(&app, &user, &id)?; let text = body.text.trim().to_string(); // Built-in commands. if text == "/compact" { @@ -319,9 +414,10 @@ async fn prompt(State(app): State, Path(id): Path, Json(body): return Ok(Json(json!({"ok": true, "command": "compact"}))); } let cwd = PathBuf::from(&session.cwd); + let root = app.root_for(session.owner_id.as_deref(), &cwd); let mut prompt_text = text.clone(); if text.starts_with('/') { - if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) { + if let Some((expanded, agent)) = prompts::expand_command(&app, &root, &cwd, &text) { prompt_text = expanded; if let Some(a) = agent { let mut s = session.clone(); @@ -334,10 +430,32 @@ async fn prompt(State(app): State, Path(id): Path, Json(body): for image in body.images { parts.push(Part::Image { image }); } - runner::start_prompt(app.clone(), id, parts)?; + match runner::submit(app.clone(), id, parts, "user")? { + runner::Submitted::Started => Ok(Json(json!({"ok": true}))), + runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))), + } +} + +async fn get_queue(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + readable(&app, &user, &id)?; + Ok(Json(json!(app.store.queue_list(&id)?))) +} + +async fn delete_queue_item(Extension(user): Extension, State(app): State, Path((id, item)): Path<(String, String)>) -> ApiResult { + writable(&app, &user, &id)?; + if !app.store.queue_remove(&id, &item)? { + return Err(ApiError(StatusCode::CONFLICT, "already delivered to the agent".into())); + } + let items = app.store.queue_list(&id)?; + app.emit(Event::QueueUpdated { session_id: id, items: json!(items) }); Ok(Json(json!({"ok": true}))) } +async fn flush_queue(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; + Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?}))) +} + async fn do_compact(app: AppState, id: String) { let cancel = tokio_util::sync::CancellationToken::new(); app.with_runtime(&id, |r| r.cancel = Some(cancel.clone())); @@ -349,12 +467,14 @@ async fn do_compact(app: AppState, id: String) { app.emit(Event::SessionStatus { session_id: id, busy: false }); } -async fn abort(State(app): State, Path(id): Path) -> Json { +async fn abort(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; runner::abort(&app, &id); - Json(json!({"ok": true})) + Ok(Json(json!({"ok": true}))) } -async fn compact(State(app): State, Path(id): Path) -> ApiResult { +async fn compact(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; if app.is_busy(&id) { return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); } @@ -368,33 +488,58 @@ struct RewindBody { } /// Remove a message and everything after it (edit & resend). Files are not restored. -async fn rewind(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { +async fn rewind(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let mut s = writable(&app, &user, &id)?; if app.is_busy(&id) { return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); } app.store.truncate_from(&id, &body.message_id)?; - let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; - let mut s = s; s.last_usage = None; app.save_session(s)?; Ok(Json(json!({"ok": true}))) } -async fn session_stats(State(app): State, Path(id): Path) -> ApiResult { +async fn session_stats(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + readable(&app, &user, &id)?; let mut v = app.store.session_stats(&id)?; v["currency"] = json!(app.config().server.currency); Ok(Json(v)) } +async fn get_shares(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; + Ok(Json(json!(app.store.shares(&id)?))) +} + +async fn put_shares(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let s = writable(&app, &user, &id)?; + if s.parent_id.is_some() { + return Err(ApiError(StatusCode::BAD_REQUEST, "share the parent session instead".into())); + } + let users: Vec = body.users.into_iter().filter(|u| Some(u) != s.owner_id.as_ref()).collect(); + app.set_shares(&id, &users)?; + Ok(Json(json!({"ok": true}))) +} + // --- interaction ------------------------------------------------------------ +/// Only users who control the session may answer its permission requests and questions. +fn check_pending(app: &App, user: &User, id: &str) -> Result<(), ApiError> { + let sid = app.pending_session(id).ok_or_else(|| not_found("request"))?; + if app.access(user, &sid) != Access::Full { + return Err(forbidden()); + } + Ok(()) +} + #[derive(Deserialize)] struct PermissionBody { reply: PermissionReply, message: Option, } -async fn reply_permission(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { +async fn reply_permission(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + check_pending(&app, &user, &id)?; app.reply_permission(&id, body.reply, body.message)?; Ok(Json(json!({"ok": true}))) } @@ -405,37 +550,55 @@ struct QuestionBody { answers: Option>, } -async fn answer_question(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { +async fn answer_question(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + check_pending(&app, &user, &id)?; app.answer_question(&id, body.answers)?; Ok(Json(json!({"ok": true}))) } -async fn pending(State(app): State) -> Json { - Json(json!(app.pending_events())) +async fn pending(Extension(user): Extension, State(app): State) -> Json { + let list: Vec = app.pending_events().into_iter().filter(|e| app.event_visible(&user, e)).collect(); + Json(json!(list)) } -async fn mcp_status(State(app): State) -> Json { - Json(app.mcp.status_json()) +async fn mcp_status(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + Ok(Json(app.mcp.status_json())) } -async fn mcp_reload(State(app): State) -> Json { +async fn mcp_reload(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; let mcp = app.mcp.clone(); let servers = app.config().mcp.clone(); let weak = Arc::downgrade(&app); mcp.start_all(weak, servers).await; - Json(app.mcp.status_json()) + Ok(Json(app.mcp.status_json())) } // --- files ------------------------------------------------------------------ +/// The directories the user may work in (own folder, shared folders). +async fn list_roots(Extension(user): Extension, State(app): State) -> Json { + let ws = app.workspace(); + let roots: Vec = app + .user_roots(Some(&user.id)) + .into_iter() + .map(|r| { + let rel = r.strip_prefix(&ws).map(|p| p.display().to_string()).unwrap_or_default(); + json!({"path": r, "name": if rel.is_empty() { "/".to_string() } else { rel }}) + }) + .collect(); + Json(json!(roots)) +} + #[derive(Deserialize)] struct DirQuery { path: Option, } -/// Subdirectories for the project picker (inside the workspace only). -async fn list_dirs(State(app): State, Query(q): Query) -> ApiResult { - let dir = validate_cwd(&app, q.path)?; +/// Subdirectories for the project picker (inside the user's roots only). +async fn list_dirs(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let dir = validate_cwd(&app, &user, q.path)?; let mut dirs = Vec::new(); for e in std::fs::read_dir(&dir)?.flatten() { let name = e.file_name().to_string_lossy().to_string(); @@ -445,15 +608,125 @@ async fn list_dirs(State(app): State, Query(q): Query) -> Ap dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()})); } dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); - let ws = app.workspace(); + let root = app.root_for(Some(&user.id), &dir); Ok(Json(json!({ "path": dir, - "parent": if dir == ws { Value::Null } else { json!(dir.parent()) }, - "workspace": ws, + "parent": if dir == root { Value::Null } else { json!(dir.parent()) }, + "root": root, + "workspace": app.workspace(), "dirs": dirs, }))) } +// --- budget ------------------------------------------------------------------- + +/// Budgets concern the whole installation: admins only. +async fn budget(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + Ok(Json(crate::budget::overview(&app).await?)) +} + +async fn provider_prices(Extension(user): Extension, State(app): State, Path(name): Path) -> ApiResult { + require_admin(&user)?; + Ok(Json(crate::budget::discover_prices(&app, &name).await?)) +} + +// --- images ------------------------------------------------------------------- + +async fn images_config(State(app): State) -> Json { + let cfg = app.config(); + let ic = &cfg.images; + Json(json!({ + "enabled": ic.enabled && !crate::config::expand_env(&ic.api_key).is_empty(), + "default_model": ic.default_model, + "output_dir": ic.output_dir, + "sizes": crate::images::SIZES, + "models": ic.models.iter().map(|m| json!({"id": m.id, "name": m.name.clone().unwrap_or_else(|| m.id.clone()), "price": m.price})).collect::>(), + "currency": cfg.server.currency, + })) +} + +async fn list_images(Extension(user): Extension, State(app): State) -> ApiResult { + Ok(Json(json!(app.store.images(Some(&user.id), 200)?))) +} + +#[derive(Deserialize)] +struct ImageBody { + prompt: String, + model: Option, + size: Option, + #[serde(default = "one")] + count: u32, + /// Target directory (absolute inside the user's folders); default: first folder/output_dir. + directory: Option, +} + +fn one() -> u32 { + 1 +} + +async fn create_images(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { + if body.prompt.trim().is_empty() { + return Err(ApiError(StatusCode::BAD_REQUEST, "prompt is empty".into())); + } + let roots = app.user_roots(Some(&user.id)); + let dir = match body.directory.filter(|d| !d.trim().is_empty()) { + Some(d) => { + let p = crate::tools::normalize(&PathBuf::from(d)); + if !roots.iter().any(|r| p.starts_with(r)) { + return Err(ApiError(StatusCode::FORBIDDEN, "directory is outside your folders".into())); + } + p + } + None => roots[0].join(&app.config().images.output_dir), + }; + let records = crate::images::generate( + &app, + crate::images::GenerateRequest { + prompt: body.prompt.trim().to_string(), + model: body.model, + size: body.size, + count: body.count, + dir, + user_id: Some(user.id.clone()), + session_id: None, + }, + ) + .await?; + Ok(Json(json!(records))) +} + +fn own_image(app: &App, user: &User, id: &str) -> Result { + let img = app.store.image(id)?.ok_or_else(|| not_found("image"))?; + if !user.is_admin() && img.user_id.as_deref() != Some(user.id.as_str()) { + return Err(ApiError(StatusCode::FORBIDDEN, "not your image".into())); + } + Ok(img) +} + +async fn image_file(Extension(user): Extension, State(app): State, Path(id): Path) -> Result { + let img = own_image(&app, &user, &id)?; + let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?; + let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + _ => "image/png", + }; + Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response()) +} + +async fn delete_image(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { + let img = own_image(&app, &user, &id)?; + // Only delete files inside the owner's folders. + let roots = app.user_roots(img.user_id.as_deref()); + let p = PathBuf::from(&img.path); + if roots.iter().any(|r| p.starts_with(r)) { + let _ = tokio::fs::remove_file(&p).await; + } + app.store.delete_image(&id)?; + Ok(Json(json!({"ok": true}))) +} + // --- statistics ------------------------------------------------------------- #[derive(Deserialize)] @@ -462,12 +735,15 @@ struct StatsQuery { to: Option, /// Minutes east of UTC (JS: -new Date().getTimezoneOffset()). tz: Option, + /// Admins: user id to filter by (omitted = all users). + user: Option, } -async fn stats(State(app): State, Query(q): Query) -> ApiResult { +async fn stats(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { let to = q.to.unwrap_or_else(|| now_ms() + 1); let from = q.from.unwrap_or(to - 30 * 86_400_000); - let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?; + let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) }; + let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?; v["currency"] = json!(app.config().server.currency); v["from"] = json!(from); v["to"] = json!(to); @@ -476,23 +752,25 @@ async fn stats(State(app): State, Query(q): Query) -> ApiR // --- websocket -------------------------------------------------------------- -async fn ws(State(app): State, upgrade: WebSocketUpgrade) -> Response { - upgrade.on_upgrade(move |socket| ws_loop(app, socket)) +async fn ws(Extension(user): Extension, State(app): State, upgrade: WebSocketUpgrade) -> Response { + upgrade.on_upgrade(move |socket| ws_loop(app, user, socket)) } -async fn ws_loop(app: AppState, mut socket: WebSocket) { +async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) { let mut rx = app.subscribe(); for ev in app.pending_events() { - if send_event(&mut socket, &ev).await.is_err() { + if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() { return; } } let mut ping = tokio::time::interval(std::time::Duration::from_secs(25)); + // Re-check the login regularly so disabled users lose their live connection. + let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60)); loop { tokio::select! { ev = rx.recv() => match ev { Ok(ev) => { - if send_event(&mut socket, &ev).await.is_err() { + if app.event_visible(&user, &ev) && send_event(&mut socket, &ev).await.is_err() { break; } } @@ -514,6 +792,11 @@ async fn ws_loop(app: AppState, mut socket: WebSocket) { break; } } + _ = recheck.tick() => { + if !matches!(app.store.user(&user.id), Ok(Some(u)) if !u.disabled) { + break; + } + } } } } diff --git a/server/src/app.rs b/server/src/app.rs index 692d721..1f70a72 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -10,7 +10,7 @@ use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; -use tokio::sync::{broadcast, oneshot}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, oneshot}; use tokio_util::sync::CancellationToken; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] @@ -39,8 +39,6 @@ pub struct Runtime { pub cancel: Option, pub read_files: HashSet, pub approved: HashSet, - /// Prompts sent while the agent was busy; injected at the next step. - pub queue: Vec, } pub struct App { @@ -53,6 +51,24 @@ pub struct App { runtime: Mutex>, permissions: Mutex>, questions: Mutex>, + /// Subagent concurrency limits: key → (size, semaphore). Recreated when the size changes. + subagent_limits: Mutex)>>, + /// One-time code for creating the first admin (None once users exist). + pub setup_code: Mutex>, + pub oidc_pending: Mutex>, + pub oidc_discovery: Mutex>, + /// session id → (root session id, owner) – both never change. + owners: Mutex)>>, + /// root session id → users it is shared with. + shares: Mutex>>, +} + +/// What a user may do with a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + None, + Read, + Full, } impl App { @@ -72,7 +88,14 @@ impl App { runtime: Mutex::default(), permissions: Mutex::default(), questions: Mutex::default(), + subagent_limits: Mutex::default(), + setup_code: Mutex::default(), + oidc_pending: Mutex::default(), + oidc_discovery: Mutex::default(), + owners: Mutex::default(), + shares: Mutex::default(), }); + crate::auth::bootstrap(&app)?; app.clone().start_mcp(); Ok(app) } @@ -97,6 +120,81 @@ impl App { std::fs::canonicalize(&ws).unwrap_or(ws) } + /// Directories a session owner may work in: admins (and legacy sessions + /// without owner) the whole workspace, users their own folder plus shared folders. + pub fn user_roots(&self, owner_id: Option<&str>) -> Vec { + let ws = self.workspace(); + let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else { + return vec![ws]; + }; + if user.is_admin() { + return vec![ws]; + } + let own = ws.join(&user.username); + let _ = std::fs::create_dir_all(&own); + let mut roots = vec![own]; + for p in self.store.folders_for_user(&user.id).unwrap_or_default() { + let p = PathBuf::from(p); + if p.starts_with(&ws) && p.is_dir() { + roots.push(p); + } + } + roots + } + + /// The root that contains `cwd` (most specific first). + pub fn root_for(&self, owner_id: Option<&str>, cwd: &Path) -> PathBuf { + let mut roots = self.user_roots(owner_id); + roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len())); + roots.iter().find(|r| cwd.starts_with(r)).cloned().unwrap_or_else(|| roots.pop().unwrap_or_else(|| self.workspace())) + } + + fn owner_of(&self, session_id: &str) -> Option<(String, Option)> { + if let Some(v) = self.owners.lock().unwrap().get(session_id) { + return Some(v.clone()); + } + let root = self.root_session_id(session_id); + let owner = self.store.get_session(&root).ok().flatten()?.owner_id; + let v = (root, owner); + self.owners.lock().unwrap().insert(session_id.to_string(), v.clone()); + Some(v) + } + + fn shared_with(&self, root: &str) -> Vec { + if let Some(v) = self.shares.lock().unwrap().get(root) { + return v.clone(); + } + let v = self.store.shares(root).unwrap_or_default(); + self.shares.lock().unwrap().insert(root.to_string(), v.clone()); + v + } + + pub fn set_shares(&self, root: &str, users: &[String]) -> Result<()> { + self.store.set_shares(root, users)?; + self.shares.lock().unwrap().insert(root.to_string(), users.to_vec()); + Ok(()) + } + + pub fn access(&self, user: &crate::store::User, session_id: &str) -> Access { + let Some((root, owner)) = self.owner_of(session_id) else { return Access::None }; + if user.is_admin() || owner.as_deref() == Some(user.id.as_str()) { + Access::Full + } else if self.shared_with(&root).contains(&user.id) { + Access::Read + } else { + Access::None + } + } + + /// Whether an event may be sent to this user's connections. + pub fn event_visible(&self, user: &crate::store::User, ev: &Event) -> bool { + match ev.session_id() { + None => user.is_admin(), + Some(_) if user.is_admin() => true, + Some(id) => self.access(user, id) != Access::None, + } + } + pub fn data_dir(&self) -> PathBuf { self.config().data_dir() } @@ -197,6 +295,54 @@ impl App { id } + fn limit(&self, key: &str, size: usize) -> Arc { + let mut m = self.subagent_limits.lock().unwrap(); + match m.get(key) { + Some((n, sem)) if *n == size => sem.clone(), + _ => { + let sem = Arc::new(Semaphore::new(size)); + m.insert(key.to_string(), (size, sem.clone())); + sem + } + } + } + + /// Wait until a subagent of this type may run (per-agent limit, then the global + /// limit – always in this order). Returns None if cancelled while waiting, and + /// whether the call had to wait. + pub async fn subagent_slot( + &self, + agent: &str, + max_parallel: Option, + session_id: &str, + cancel: &CancellationToken, + ) -> (Option>, bool) { + let mut sems = Vec::new(); + if let Some(n) = max_parallel.filter(|n| *n > 0) { + sems.push((format!("subagent:{agent}"), self.limit(&format!("agent:{agent}"), n))); + } + let global = self.config().server.max_parallel_subagents; + if global > 0 { + sems.push(("subagent:*".to_string(), self.limit("*", global))); + } + let mut waited = false; + let mut permits = Vec::new(); + for (reason, sem) in sems { + if sem.available_permits() == 0 { + waited = true; + self.emit(Event::Waiting { session_id: session_id.to_string(), reason: Some(reason) }); + } + tokio::select! { + p = sem.acquire_owned() => match p { + Ok(p) => permits.push(p), + Err(_) => return (None, waited), + }, + _ = cancel.cancelled() => return (None, waited), + } + } + (Some(permits), waited) + } + // --- user interaction ------------------------------------------------- /// Forward questions to the UI and wait. None = dismissed. @@ -227,6 +373,12 @@ impl App { Ok(res) } + /// Session of a pending permission request or question. + pub fn pending_session(&self, id: &str) -> Option { + let from_perm = self.permissions.lock().unwrap().get(id).and_then(|p| p.event.session_id().map(String::from)); + from_perm.or_else(|| self.questions.lock().unwrap().get(id).and_then(|q| q.event.session_id().map(String::from))) + } + pub fn answer_question(&self, id: &str, answers: Option>) -> Result<()> { let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?; let _ = p.tx.send(answers); diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..2d620d7 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,612 @@ +//! Users, sign-in (local passwords + OpenID Connect) and access control. + +use crate::api::{ApiError, ApiResult}; +use crate::app::App; +use crate::config::expand_env; +use crate::store::User; +use anyhow::{Context, Result, anyhow, bail}; +use argon2::{Argon2, PasswordHasher, PasswordVerifier}; +use axum::extract::{Path, Query, Request, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::{Extension, Json}; +use base64::Engine; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +pub const COOKIE: &str = "codecraft_session"; +const MIN_PASSWORD: usize = 8; + +// --- primitives --------------------------------------------------------------- + +pub fn random_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::fill(&mut buf[..]); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) +} + +pub fn sha256_hex(s: &str) -> String { + Sha256::digest(s.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() +} + +pub fn hash_password(password: &str) -> Result { + let mut salt = [0u8; 16]; + rand::fill(&mut salt); + let hash = Argon2::default() + .hash_password_with_salt(password.as_bytes(), &salt) + .map_err(|e| anyhow!("hashing failed: {e}"))?; + Ok(hash.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + Argon2::default().verify_password(password.as_bytes(), hash).is_ok() +} + +fn valid_username(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + && !name.starts_with('.') +} + +/// Usernames become directory names; derive a safe one from an IdP claim. +fn sanitize_username(raw: &str) -> String { + let local = raw.split('@').next().unwrap_or(raw); + let s: String = local + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c.to_ascii_lowercase() } else { '_' }) + .collect(); + s.trim_start_matches('.').chars().take(64).collect() +} + +// --- cookies & middleware ------------------------------------------------------- + +fn cookie_secure(app: &App, headers: &HeaderMap) -> bool { + match app.config().auth.cookie_secure.as_str() { + "true" => true, + "false" => false, + _ => { + app.config().auth.public_url.starts_with("https://") + || headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) == Some("https") + } + } +} + +fn session_cookie(app: &App, headers: &HeaderMap, token: &str, max_age_secs: i64) -> HeaderValue { + let secure = if cookie_secure(app, headers) { "; Secure" } else { "" }; + HeaderValue::from_str(&format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}")) + .expect("valid cookie") +} + +pub fn token_from_headers(headers: &HeaderMap) -> Option { + if let Some(t) = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + { + return Some(t.trim().to_string()); + } + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|c| c.split(';')) + .find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from)) +} + +pub fn current_user(app: &App, headers: &HeaderMap) -> Option { + let token = token_from_headers(headers)?; + app.store.user_by_token(&sha256_hex(&token)).ok().flatten() +} + +/// Paths reachable without a login. +fn is_public(path: &str) -> bool { + ["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"] + .iter() + .any(|p| path.ends_with(p)) +} + +pub async fn require_user(State(app): State>, mut req: Request, next: Next) -> Response { + if is_public(req.uri().path()) { + return next.run(req).await; + } + match current_user(&app, req.headers()) { + Some(user) => { + req.extensions_mut().insert(user); + next.run(req).await + } + None => ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response(), + } +} + +pub fn require_admin(user: &User) -> Result<(), ApiError> { + if user.is_admin() { Ok(()) } else { Err(ApiError(StatusCode::FORBIDDEN, "admin only".into())) } +} + +fn issue_login(app: &App, headers: &HeaderMap, user: &User) -> Result { + let token = random_token(32); + let days = app.config().auth.session_days.max(1) as i64; + let ua = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or(""); + app.store.add_token(&sha256_hex(&token), &user.id, crate::types::now_ms() + days * 86_400_000, &ua.chars().take(200).collect::())?; + Ok(session_cookie(app, headers, &token, days * 86_400)) +} + +// --- bootstrap ------------------------------------------------------------------ + +/// Create the first admin from the environment, or generate a setup code. +pub fn bootstrap(app: &App) -> Result<()> { + if app.store.user_count()? > 0 { + return Ok(()); + } + if let (Ok(user), Ok(pass)) = (std::env::var("CODECRAFT_ADMIN_USER"), std::env::var("CODECRAFT_ADMIN_PASSWORD")) { + if valid_username(&user) && pass.len() >= MIN_PASSWORD { + let u = app.store.create_user(&user, &user, "admin", Some(&hash_password(&pass)?), None)?; + let n = app.store.adopt_orphans(&u.id)?; + tracing::info!("created admin '{user}' from the environment ({n} existing sessions assigned)"); + return Ok(()); + } + tracing::warn!("CODECRAFT_ADMIN_USER/PASSWORD ignored: invalid username or password shorter than {MIN_PASSWORD}"); + } + let code = random_token(9); + tracing::warn!("no users yet – open the web UI and create the first admin with setup code: {code}"); + *app.setup_code.lock().unwrap() = Some(code); + Ok(()) +} + +// --- endpoints: session ------------------------------------------------------- + +pub async fn info(State(app): State>, headers: HeaderMap) -> Json { + let cfg = app.config(); + let user = current_user(&app, &headers); + Json(json!({ + "name": "CodeCraft", + "version": env!("CARGO_PKG_VERSION"), + "setup_required": app.setup_code.lock().unwrap().is_some(), + "oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null }, + "user": user, + })) +} + +#[derive(Deserialize)] +pub struct LoginBody { + username: String, + password: String, +} + +pub async fn login(State(app): State>, headers: HeaderMap, Json(body): Json) -> ApiResult { + let found = app.store.user_by_name(body.username.trim())?; + // Verify even for unknown users so timing does not reveal which names exist. + let hash = found.as_ref().and_then(|(_, h)| h.clone()); + let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy")); + let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into())); + }; + let cookie = issue_login(&app, &headers, &user)?; + Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response()) +} + +pub async fn logout(State(app): State>, headers: HeaderMap) -> Response { + if let Some(t) = token_from_headers(&headers) { + let _ = app.store.delete_token(&sha256_hex(&t)); + } + ([(header::SET_COOKIE, session_cookie(&app, &headers, "", 0))], Json(json!({"ok": true}))).into_response() +} + +#[derive(Deserialize)] +pub struct SetupBody { + code: String, + username: String, + password: String, +} + +pub async fn setup(State(app): State>, headers: HeaderMap, Json(body): Json) -> ApiResult { + let expected = app.setup_code.lock().unwrap().clone(); + let Some(expected) = expected else { + return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into())); + }; + if body.code.trim() != expected { + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into())); + } + let name = body.username.trim(); + if !valid_username(name) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username".into())); + } + if body.password.len() < MIN_PASSWORD { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters"))); + } + let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?; + app.store.adopt_orphans(&user.id)?; + *app.setup_code.lock().unwrap() = None; + let cookie = issue_login(&app, &headers, &user)?; + Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response()) +} + +#[derive(Deserialize)] +pub struct PasswordBody { + current: Option, + new: String, +} + +/// Change the own password (OIDC-only users may set one without `current`). +pub async fn change_password(State(app): State>, Extension(user): Extension, headers: HeaderMap, Json(body): Json) -> ApiResult { + if body.new.len() < MIN_PASSWORD { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters"))); + } + if let Some((_, Some(hash))) = app.store.user_by_name(&user.username)? { + if !verify_password(body.current.as_deref().unwrap_or(""), &hash) { + return Err(ApiError(StatusCode::UNAUTHORIZED, "current password is wrong".into())); + } + } + app.store.update_user(&user.id, None, None, None, Some(&hash_password(&body.new)?), None)?; + // Sign out all other devices. + app.store.delete_user_tokens(&user.id)?; + let _ = issue_login(&app, &headers, &user)?; + Ok(Json(json!({"ok": true}))) +} + +// --- endpoints: user management (admin) ------------------------------------------ + +pub async fn list_users(Extension(me): Extension, State(app): State>) -> ApiResult { + require_admin(&me)?; + Ok(Json(json!(app.store.users()?))) +} + +/// Minimal user list for sharing dialogs (all signed-in users). +pub async fn directory(State(app): State>) -> ApiResult { + let list: Vec = app + .store + .users()? + .into_iter() + .filter(|u| !u.disabled) + .map(|u| json!({"id": u.id, "username": u.username, "display_name": u.display_name})) + .collect(); + Ok(Json(json!(list))) +} + +#[derive(Deserialize)] +pub struct CreateUserBody { + username: String, + #[serde(default)] + display_name: String, + password: Option, + #[serde(default = "default_role")] + role: String, +} + +fn default_role() -> String { + "user".into() +} + +fn check_role(role: &str) -> Result<(), ApiError> { + if role == "admin" || role == "user" { Ok(()) } else { Err(ApiError(StatusCode::BAD_REQUEST, "role must be admin or user".into())) } +} + +pub async fn create_user(Extension(me): Extension, State(app): State>, Json(body): Json) -> ApiResult { + require_admin(&me)?; + let name = body.username.trim(); + if !valid_username(name) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username (letters, digits, - _ .)".into())); + } + check_role(&body.role)?; + let hash = match body.password.as_deref().filter(|p| !p.is_empty()) { + Some(p) if p.len() < MIN_PASSWORD => { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters"))); + } + Some(p) => Some(hash_password(p)?), + None => None, + }; + if app.store.user_by_name(name)?.is_some() { + return Err(ApiError(StatusCode::CONFLICT, "username already exists".into())); + } + let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() }; + let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?; + Ok(Json(json!(user))) +} + +#[derive(Deserialize)] +pub struct UpdateUserBody { + display_name: Option, + role: Option, + disabled: Option, + password: Option, +} + +pub async fn update_user(Extension(me): Extension, State(app): State>, Path(id): Path, Json(body): Json) -> ApiResult { + require_admin(&me)?; + let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?; + if let Some(r) = &body.role { + check_role(r)?; + } + let loses_admin = target.is_admin() && !target.disabled && (body.role.as_deref().is_some_and(|r| r != "admin") || body.disabled == Some(true)); + if loses_admin && app.store.admin_count()? <= 1 { + return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be demoted or disabled".into())); + } + let hash = match body.password.as_deref().filter(|p| !p.is_empty()) { + Some(p) if p.len() < MIN_PASSWORD => { + return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters"))); + } + Some(p) => Some(hash_password(p)?), + None => None, + }; + app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?; + if hash.is_some() { + app.store.delete_user_tokens(&id)?; + } + Ok(Json(json!(app.store.user(&id)?))) +} + +pub async fn delete_user(Extension(me): Extension, State(app): State>, Path(id): Path) -> ApiResult { + require_admin(&me)?; + let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?; + if target.id == me.id { + return Err(ApiError(StatusCode::CONFLICT, "you cannot delete yourself".into())); + } + if target.is_admin() && app.store.admin_count()? <= 1 { + return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be deleted".into())); + } + // Sessions stay (statistics); they become visible to admins only. + app.store.delete_user(&id)?; + Ok(Json(json!({"ok": true}))) +} + +// --- OpenID Connect ----------------------------------------------------------------- + +pub struct OidcPending { + nonce: String, + verifier: String, + created: std::time::Instant, +} + +async fn discovery(app: &App) -> Result { + let cfg = app.config(); + let issuer = expand_env(&cfg.auth.oidc.issuer); + if let Some((iss, doc)) = app.oidc_discovery.lock().unwrap().clone() { + if iss == issuer { + return Ok(doc); + } + } + let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/')); + let doc: Value = crate::provider::http_client().get(&url).send().await?.error_for_status()?.json().await?; + *app.oidc_discovery.lock().unwrap() = Some((issuer, doc.clone())); + Ok(doc) +} + +fn redirect_uri(app: &App) -> Result { + let base = app.config().auth.public_url.trim_end_matches('/').to_string(); + if base.is_empty() { + bail!("auth.public_url must be set for single sign-on"); + } + Ok(format!("{base}/api/auth/oidc/callback")) +} + +fn error_page(message: &str) -> Response { + let msg = message.replace('&', "&").replace('<', "<").replace('>', ">"); + ( + StatusCode::FORBIDDEN, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + format!("CodeCraft

Sign-in failed

{msg}

Back

"), + ) + .into_response() +} + +pub async fn oidc_start(State(app): State>) -> Response { + match oidc_start_inner(&app).await { + Ok(url) => Redirect::to(&url).into_response(), + Err(e) => error_page(&format!("{e:#}")), + } +} + +async fn oidc_start_inner(app: &App) -> Result { + let cfg = app.config(); + if !cfg.auth.oidc.enabled { + bail!("single sign-on is not enabled"); + } + let doc = discovery(app).await?; + let endpoint = doc["authorization_endpoint"].as_str().context("no authorization_endpoint")?; + let state = random_token(24); + let nonce = random_token(24); + let verifier = random_token(48); + let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + { + let mut pending = app.oidc_pending.lock().unwrap(); + pending.retain(|_, p| p.created.elapsed().as_secs() < 600); + pending.insert(state.clone(), OidcPending { nonce: nonce.clone(), verifier, created: std::time::Instant::now() }); + } + let mut url = reqwest::Url::parse(endpoint)?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &expand_env(&cfg.auth.oidc.client_id)) + .append_pair("redirect_uri", &redirect_uri(app)?) + .append_pair("scope", &cfg.auth.oidc.scopes.join(" ")) + .append_pair("state", &state) + .append_pair("nonce", &nonce) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url.to_string()) +} + +#[derive(Deserialize)] +pub struct CallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + +pub async fn oidc_callback(State(app): State>, headers: HeaderMap, Query(q): Query) -> Response { + if let Some(e) = q.error { + return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default())); + } + match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await { + Ok(user) => match issue_login(&app, &headers, &user) { + Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(), + Err(e) => error_page(&format!("{e:#}")), + }, + Err(e) => { + tracing::warn!("oidc sign-in failed: {e:#}"); + error_page(&format!("{e:#}")) + } + } +} + +fn decode_jwt_claims(jwt: &str) -> Result { + let payload = jwt.split('.').nth(1).context("malformed id_token")?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.trim_end_matches('=')) + .context("malformed id_token payload")?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn claim_list(v: &Value) -> Vec { + match v { + Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(), + Value::String(s) => s.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(), + _ => vec![], + } +} + +async fn oidc_callback_inner(app: &App, code: String, state: String) -> Result { + let cfg = app.config(); + let oc = &cfg.auth.oidc; + let pending = app.oidc_pending.lock().unwrap().remove(&state).context("unknown or expired sign-in attempt – please try again")?; + let doc = discovery(app).await?; + let token_endpoint = doc["token_endpoint"].as_str().context("no token_endpoint")?; + let client_id = expand_env(&oc.client_id); + let resp = crate::provider::http_client() + .post(token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", redirect_uri(app)?.as_str()), + ("client_id", client_id.as_str()), + ("client_secret", expand_env(&oc.client_secret).as_str()), + ("code_verifier", pending.verifier.as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + bail!("token exchange failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default().chars().take(300).collect::()); + } + let tokens: Value = resp.json().await?; + // The id_token comes straight from the token endpoint over TLS, which + // authenticates the issuer (OIDC Core 3.1.3.7); we still check its claims. + let mut claims = decode_jwt_claims(tokens["id_token"].as_str().context("no id_token in response")?)?; + let issuer = expand_env(&oc.issuer); + if claims["iss"].as_str().map(|s| s.trim_end_matches('/')) != Some(issuer.trim_end_matches('/')) { + bail!("id_token issuer mismatch"); + } + let aud_ok = match &claims["aud"] { + Value::String(a) => *a == client_id, + Value::Array(a) => a.iter().any(|x| x.as_str() == Some(&client_id)), + _ => false, + }; + if !aud_ok { + bail!("id_token audience mismatch"); + } + if claims["nonce"].as_str() != Some(pending.nonce.as_str()) { + bail!("id_token nonce mismatch"); + } + if claims["exp"].as_i64().unwrap_or(0) < chrono::Utc::now().timestamp() { + bail!("id_token expired"); + } + // Groups and names are often only in the userinfo response. + if let (Some(ep), Some(at)) = (doc["userinfo_endpoint"].as_str(), tokens["access_token"].as_str()) { + if let Ok(r) = crate::provider::http_client().get(ep).bearer_auth(at).send().await { + if let Ok(info) = r.json::().await { + if info["sub"] == claims["sub"] { + for (k, v) in info.as_object().cloned().unwrap_or_default() { + if claims.get(&k).is_none() { + claims[&k] = v; + } + } + } + } + } + } + let sub = claims["sub"].as_str().context("no sub claim")?.to_string(); + let groups = claim_list(&claims[&oc.groups_claim]); + if !oc.allowed_group.is_empty() && !groups.contains(&oc.allowed_group) { + bail!("your account is not allowed to use CodeCraft"); + } + let is_admin = !oc.admin_group.is_empty() && groups.contains(&oc.admin_group); + let display = claims["name"].as_str().unwrap_or("").to_string(); + + let user = if let Some(u) = app.store.user_by_oidc(&sub)? { + u + } else { + let raw = claims[&oc.username_claim] + .as_str() + .or_else(|| claims["preferred_username"].as_str()) + .or_else(|| claims["email"].as_str()) + .unwrap_or(&sub); + let name = sanitize_username(raw); + if !valid_username(&name) { + bail!("cannot derive a valid username from the '{}' claim", oc.username_claim); + } + match app.store.user_by_name(&name)? { + Some((existing, _)) if oc.link_by_username && existing.oidc_sub_free() => { + app.store.update_user(&existing.id, None, None, None, None, Some(&sub))?; + existing + } + Some(_) => bail!("the username '{name}' is already taken by a local account"), + None if oc.auto_create => { + let first = app.store.user_count()? == 0; + let role = if is_admin || first { "admin" } else { "user" }; + let u = app.store.create_user(&name, if display.is_empty() { &name } else { &display }, role, None, Some(&sub))?; + if first { + app.store.adopt_orphans(&u.id)?; + *app.setup_code.lock().unwrap() = None; + } + u + } + None => bail!("no CodeCraft account exists for '{name}'"), + } + }; + if user.disabled { + bail!("this account is disabled"); + } + // Keep the role in sync with the IdP group (never removing the last admin). + if !oc.admin_group.is_empty() && user.is_admin() != is_admin && (is_admin || app.store.admin_count()? > 1) { + app.store.update_user(&user.id, None, Some(if is_admin { "admin" } else { "user" }), None, None, None)?; + } + Ok(app.store.user(&user.id)?.context("user vanished")?) +} + +impl User { + fn oidc_sub_free(&self) -> bool { + !self.oidc + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_roundtrip() { + let h = hash_password("correct horse").unwrap(); + assert!(verify_password("correct horse", &h)); + assert!(!verify_password("wrong", &h)); + } + + #[test] + fn usernames() { + assert!(valid_username("zink")); + assert!(!valid_username("../etc")); + assert!(!valid_username(".hidden")); + assert_eq!(sanitize_username("Richard.Zink@example.org"), "richard.zink"); + assert_eq!(sanitize_username("a/b c"), "a_b_c"); + } + + #[test] + fn jwt_claims() { + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"1","aud":"x"}"#); + let v = decode_jwt_claims(&format!("h.{payload}.s")).unwrap(); + assert_eq!(v["sub"], "1"); + } +} diff --git a/server/src/budget.rs b/server/src/budget.rs new file mode 100644 index 0000000..0b4ea65 --- /dev/null +++ b/server/src/budget.rs @@ -0,0 +1,223 @@ +//! Budgets: monthly limits tracked from recorded costs, plus the real balance +//! for providers that expose one (OpenRouter, fal.ai), and price discovery. + +use crate::app::App; +use crate::config::{ProviderConfig, expand_env}; +use anyhow::{Context, Result, bail}; +use chrono::{Datelike, TimeZone}; +use serde_json::{Value, json}; +use std::time::Duration; + +/// Start of the current month in local time (ms). +pub fn month_start() -> i64 { + let now = chrono::Local::now(); + chrono::Local + .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .single() + .map(|d| d.timestamp_millis()) + .unwrap_or(0) +} + +fn is_openrouter(p: &ProviderConfig) -> bool { + p.base_url.contains("openrouter.ai") +} + +/// Month-to-date cost from Anthropic's cost report (organisation-wide, USD). +async fn anthropic_cost(p: &ProviderConfig) -> Result { + let admin = expand_env(&p.admin_key); + let start = chrono::DateTime::from_timestamp_millis(month_start()).unwrap_or_default(); + let end = chrono::Utc::now() + chrono::Duration::days(1); + let base = if p.base_url.is_empty() { "https://api.anthropic.com/v1".to_string() } else { expand_env(&p.base_url).trim_end_matches('/').to_string() }; + let mut total_cents = 0.0; + let mut page: Option = None; + for _ in 0..10 { + let mut url = reqwest::Url::parse(&format!("{base}/organizations/cost_report"))?; + url.query_pairs_mut() + .append_pair("starting_at", &start.format("%Y-%m-%dT00:00:00Z").to_string()) + .append_pair("ending_at", &end.format("%Y-%m-%dT00:00:00Z").to_string()) + .append_pair("limit", "31"); + if let Some(pg) = &page { + url.query_pairs_mut().append_pair("page", pg); + } + let resp = crate::provider::http_client() + .get(url) + .header("x-api-key", &admin) + .header("anthropic-version", "2026-10-21") + .timeout(Duration::from_secs(20)) + .send() + .await?; + if !resp.status().is_success() { + return Err(crate::provider::http_error(resp).await); + } + let v: Value = resp.json().await?; + for bucket in v["data"].as_array().cloned().unwrap_or_default() { + for r in bucket["results"].as_array().cloned().unwrap_or_default() { + // Amounts are decimal strings in cents. + total_cents += r["amount"].as_str().and_then(|a| a.parse::().ok()).or_else(|| r["amount"].as_f64()).unwrap_or(0.0); + } + } + if v["has_more"].as_bool() != Some(true) { + break; + } + page = v["next_page"].as_str().map(String::from); + } + Ok(json!({"currency": "USD", "spent_month": total_cents / 100.0, "scope": "organization"})) +} + +/// Balance or cost as reported by the provider, if it has an API for it. +async fn remote_balance(p: &ProviderConfig) -> Option> { + if p.kind == crate::config::ProviderKind::Anthropic && !expand_env(&p.admin_key).is_empty() { + return Some(anthropic_cost(p).await); + } + if !is_openrouter(p) { + return None; + } + Some( + async { + let base = expand_env(&p.base_url).trim_end_matches('/').to_string(); + let resp = crate::provider::http_client() + .get(format!("{base}/key")) + .bearer_auth(expand_env(&p.api_key)) + .timeout(Duration::from_secs(15)) + .send() + .await? + .error_for_status()?; + let v: Value = resp.json().await?; + let d = &v["data"]; + Ok(json!({ + "currency": "USD", + "limit": d["limit"], + "used": d["usage"], + "remaining": d.get("limit_remaining").cloned().unwrap_or_else(|| match (d["limit"].as_f64(), d["usage"].as_f64()) { + (Some(l), Some(u)) => json!(l - u), + _ => Value::Null, + }), + })) + } + .await, + ) +} + +async fn fal_balance(admin_key: &str) -> Result { + let resp = crate::provider::http_client() + .get("https://api.fal.ai/v1/account/billing?expand=credits") + .header("Authorization", format!("Key {admin_key}")) + .timeout(Duration::from_secs(15)) + .send() + .await?; + if !resp.status().is_success() { + return Err(crate::provider::http_error(resp).await); + } + let v: Value = resp.json().await?; + Ok(json!({ + "currency": v["credits"]["currency"], + "remaining": v["credits"]["current_balance"], + })) +} + +fn entry(name: &str, budget: Option, spent: f64, warn: f64) -> Value { + let share = budget.filter(|b| *b > 0.0).map(|b| spent / b); + json!({ + "provider": name, + "budget": budget, + "spent": spent, + "remaining": budget.map(|b| b - spent), + "share": share, + "warning": share.is_some_and(|s| s >= warn), + "exceeded": share.is_some_and(|s| s >= 1.0), + }) +} + +/// Budget overview for the current month. +pub async fn overview(app: &App) -> Result { + let cfg = app.config(); + let spent = app.store.cost_by_provider(month_start())?; + let warn = cfg.server.budget_warning; + let mut rows = Vec::new(); + let mut tasks = Vec::new(); + for (name, p) in &cfg.providers { + if !p.enabled { + continue; + } + rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn)); + let p = p.clone(); + tasks.push(tokio::spawn(async move { remote_balance(&p).await })); + } + for (row, task) in rows.iter_mut().zip(tasks) { + if let Ok(Some(res)) = task.await { + row["remote"] = match res { + Ok(v) => v, + Err(e) => json!({"error": format!("{e:#}")}), + }; + } + } + if cfg.images.enabled { + let mut row = entry("fal", cfg.images.monthly_budget, spent.get("fal").copied().unwrap_or(0.0), warn); + let admin = expand_env(&cfg.images.admin_key); + if !admin.is_empty() { + row["remote"] = match fal_balance(&admin).await { + Ok(v) => v, + Err(e) => json!({"error": format!("{e:#}")}), + }; + } + row["images"] = json!(true); + rows.push(row); + } + let total: f64 = spent.values().sum(); + let global = (cfg.server.monthly_budget > 0.0).then_some(cfg.server.monthly_budget); + Ok(json!({ + "month_start": month_start(), + "currency": cfg.server.currency, + "total": entry("total", global, total, warn), + "providers": rows, + })) +} + +/// Per-token prices from the provider's model list, converted to per 1M tokens. +pub async fn discover_prices(app: &App, provider: &str) -> Result { + let cfg = app.config(); + let p = cfg.providers.get(provider).context("unknown provider")?; + if !is_openrouter(p) { + bail!("this provider does not publish prices via its API – enter them manually"); + } + let base = expand_env(&p.base_url).trim_end_matches('/').to_string(); + let v: Value = crate::provider::http_client() + .get(format!("{base}/models")) + .timeout(Duration::from_secs(30)) + .send() + .await? + .error_for_status()? + .json() + .await?; + let per_m = |x: &Value| x.as_str().and_then(|s| s.parse::().ok()).or_else(|| x.as_f64()).map(|f| (f * 1_000_000.0 * 10_000.0).round() / 10_000.0); + let list: Vec = v["data"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|m| { + json!({ + "id": m["id"], + "context": m["context_length"], + "input_price": per_m(&m["pricing"]["prompt"]), + "output_price": per_m(&m["pricing"]["completion"]), + "cache_read_price": per_m(&m["pricing"]["input_cache_read"]), + "cache_write_price": per_m(&m["pricing"]["input_cache_write"]), + }) + }) + .collect(); + Ok(json!({"currency": "USD", "models": list})) +} + +#[cfg(test)] +mod tests { + #[test] + fn budget_entry_flags() { + let e = super::entry("x", Some(10.0), 8.5, 0.8); + assert_eq!(e["warning"], true); + assert_eq!(e["exceeded"], false); + assert_eq!(e["remaining"], 1.5); + let none = super::entry("y", None, 3.0, 0.8); + assert_eq!(none["warning"], false); + } +} diff --git a/server/src/config.rs b/server/src/config.rs index b5d95b0..822135e 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -19,6 +19,118 @@ pub struct Config { pub permissions: PermissionConfig, pub agents: BTreeMap, pub craft: CraftConfig, + pub auth: AuthConfig, + pub images: ImagesConfig, +} + +/// Image generation via fal.ai. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct ImagesConfig { + pub enabled: bool, + pub base_url: String, + pub api_key: String, + pub default_model: String, + /// Directory (relative to the session/project directory) for generated images. + pub output_dir: String, + pub models: Vec, + /// Monthly budget for image generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub monthly_budget: Option, + /// Optional fal.ai Admin key to query the account balance. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub admin_key: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ImageModel { + pub id: String, + #[serde(default)] + pub name: Option, + /// Price per image (statistics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub price: Option, + /// "image_size" (FLUX style enums) or "aspect_ratio" ("16:9" style). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_param: Option, + /// Extra request fields sent as-is. + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub options: serde_json::Map, +} + +impl Default for ImagesConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: "https://fal.run".into(), + api_key: "${FAL_KEY}".into(), + default_model: "fal-ai/flux/schnell".into(), + output_dir: "generated".into(), + models: vec![ImageModel { id: "fal-ai/flux/schnell".into(), name: Some("FLUX.1 schnell".into()), price: Some(0.003), ..Default::default() }], + monthly_budget: None, + admin_key: String::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct AuthConfig { + /// Lifetime of a login in days. + pub session_days: u32, + /// External URL, e.g. https://codecraft.example.org (needed for OIDC). + pub public_url: String, + /// "auto" (secure when served via https), "true" or "false". + pub cookie_secure: String, + pub oidc: OidcConfig, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { session_days: 30, public_url: String::new(), cookie_secure: "auto".into(), oidc: OidcConfig::default() } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct OidcConfig { + pub enabled: bool, + pub issuer: String, + pub client_id: String, + pub client_secret: String, + pub scopes: Vec, + /// Claim used as username. + pub username_claim: String, + pub groups_claim: String, + /// Members of this group become admins (empty = roles are managed in CodeCraft). + pub admin_group: String, + /// Only members of this group may sign in (empty = everybody the IdP lets through). + pub allowed_group: String, + /// Create users on first login. + pub auto_create: bool, + /// Link an IdP login to an existing local user with the same username. + /// Only enable if you trust the IdP's usernames. + pub link_by_username: bool, + pub button_label: String, +} + +impl Default for OidcConfig { + fn default() -> Self { + Self { + enabled: false, + issuer: String::new(), + client_id: String::new(), + client_secret: String::new(), + scopes: vec!["openid".into(), "profile".into(), "email".into(), "groups".into()], + username_claim: "preferred_username".into(), + groups_claim: "groups".into(), + admin_group: String::new(), + allowed_group: String::new(), + auto_create: true, + link_by_username: false, + button_label: "Single sign-on".into(), + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -27,12 +139,19 @@ pub struct ServerConfig { pub bind: String, pub workspace: String, pub data_dir: String, - pub auth_token: String, pub default_model: String, pub small_model: String, pub auto_compact_threshold: f64, /// Currency label for cost statistics (display only). pub currency: String, + /// Max. subagents running at the same time (all sessions). + pub max_parallel_subagents: usize, + /// MCP servers with more tools than this are loaded on demand (load = "auto"). + pub mcp_direct_limit: usize, + /// Monthly budget for all providers together (0 = none). + pub monthly_budget: f64, + /// Warn when this share of a budget is used (0.0–1.0). + pub budget_warning: f64, } impl Default for ServerConfig { @@ -41,11 +160,14 @@ impl Default for ServerConfig { bind: "127.0.0.1:8787".into(), workspace: ".".into(), data_dir: "./data".into(), - auth_token: String::new(), default_model: String::new(), small_model: String::new(), auto_compact_threshold: 0.85, currency: "USD".into(), + max_parallel_subagents: 4, + mcp_direct_limit: 15, + monthly_budget: 0.0, + budget_warning: 0.8, } } } @@ -74,6 +196,16 @@ pub struct ProviderConfig { pub models: Vec, #[serde(default = "yes")] pub enabled: bool, + /// Max. simultaneous requests to this provider/host (None = unlimited). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, + /// Monthly budget in the configured currency (tracked from recorded costs). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub monthly_budget: Option, + /// Optional admin key to read the provider's own cost report + /// (Anthropic: sk-ant-admin01-…, organisation-wide). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub admin_key: String, } fn yes() -> bool { @@ -112,6 +244,12 @@ pub struct ModelConfig { pub cache_read_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_price: Option, + /// Max. simultaneous requests to this model (None = unlimited). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_concurrency: Option, + /// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens). + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub options: serde_json::Map, } impl ModelConfig { @@ -151,6 +289,9 @@ pub struct McpConfig { pub headers: BTreeMap, #[serde(default = "yes")] pub enabled: bool, + /// "auto" (default), "always" or "on_demand": how the server's tools reach the model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub load: Option, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] @@ -180,6 +321,9 @@ pub struct AgentConfig { pub tools: Option>, #[serde(default)] pub prompt: Option, + /// Max. instances of this subagent running at the same time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_parallel: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -224,10 +368,6 @@ impl Config { pub fn data_dir(&self) -> PathBuf { PathBuf::from(expand_env(&self.server.data_dir)) } - - pub fn auth_token(&self) -> String { - expand_env(&self.server.auth_token) - } } /// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker). @@ -273,9 +413,12 @@ pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result { let (last, parents) = patch.path.split_last().unwrap(); let mut table: &mut toml_edit::Table = doc.as_table_mut(); for key in parents { - let entry = table - .entry(key) - .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new())); + let entry = table.entry(key).or_insert_with(|| { + let mut t = toml_edit::Table::new(); + // No empty "[agents]" header when only "[agents.x]" is needed. + t.set_implicit(true); + toml_edit::Item::Table(t) + }); // Inline tables are converted so we can descend into them. if let Some(inline) = entry.as_inline_table().cloned() { *entry = toml_edit::Item::Table(inline.into_table()); @@ -327,6 +470,11 @@ fn json_to_toml(v: &serde_json::Value) -> Option { n.as_f64()?.into() } } + J::String(s) if s.contains('\n') && !s.contains("'''") && !s.contains('\r') => { + // Multi-line text (prompts) as a readable literal block. + let body = if s.ends_with('\n') { s.clone() } else { format!("{s}\n") }; + format!("'''\n{body}'''").parse::().unwrap_or_else(|_| s.as_str().into()) + } J::String(s) => s.as_str().into(), J::Array(a) => { let mut arr = toml_edit::Array::new(); @@ -369,12 +517,20 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value { }; if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) { for p in ps.values_mut() { - if let Some(k) = p.get_mut("api_key") { - mask(k); + for field in ["api_key", "admin_key"] { + if let Some(k) = p.get_mut(field) { + mask(k); + } } } } - if let Some(t) = v.pointer_mut("/server/auth_token") { + if let Some(t) = v.pointer_mut("/auth/oidc/client_secret") { + mask(t); + } + if let Some(t) = v.pointer_mut("/images/api_key") { + mask(t); + } + if let Some(t) = v.pointer_mut("/images/admin_key") { mask(t); } v @@ -408,6 +564,23 @@ mod tests { assert!(cfg.providers["gemini"].enabled); } + #[test] + fn patch_multiline_prompt_roundtrips() { + let prompt = "You write docs.\nUse \"quotes\" and \\backslashes."; + let out = apply_patches( + EXAMPLE_CONFIG, + &[ConfigPatch { + path: vec!["agents".into(), "writer".into(), "prompt".into()], + value: serde_json::json!(prompt), + }], + ) + .unwrap(); + assert!(out.contains("prompt = '''\nYou write docs.")); + assert!(!out.contains("[agents]\n")); + let cfg = Config::parse(&out).unwrap(); + assert_eq!(cfg.agents["writer"].prompt.as_deref().unwrap().trim_end(), prompt); + } + #[test] fn patch_adds_provider_section() { let out = apply_patches( diff --git a/server/src/files.rs b/server/src/files.rs new file mode 100644 index 0000000..6495fc7 --- /dev/null +++ b/server/src/files.rs @@ -0,0 +1,300 @@ +//! File manager API: browse, edit, upload, move and delete inside the user's folders. + +use crate::api::{ApiError, ApiResult}; +use crate::app::App; +use crate::store::User; +use axum::body::Bytes; +use axum::extract::{Query, State}; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +type AppState = Arc; + +const MAX_TEXT: u64 = 2 * 1024 * 1024; + +fn bad(msg: impl Into) -> ApiError { + ApiError(StatusCode::BAD_REQUEST, msg.into()) +} + +/// Resolve a path and make sure it (or, for new files, its parent) lies inside +/// one of the user's folders. Symlinks are resolved before the check. +pub fn checked_path(app: &App, user: &User, raw: &str, must_exist: bool) -> Result { + let roots = app.user_roots(Some(&user.id)); + let p = crate::tools::normalize(Path::new(raw.trim())); + if !p.is_absolute() { + return Err(bad("path must be absolute")); + } + let real = if p.exists() { + std::fs::canonicalize(&p).map_err(|e| bad(e.to_string()))? + } else { + if must_exist { + return Err(ApiError(StatusCode::NOT_FOUND, format!("{} does not exist", p.display()))); + } + let parent = p.parent().ok_or_else(|| bad("invalid path"))?; + let parent = std::fs::canonicalize(parent).map_err(|_| bad("parent directory does not exist"))?; + parent.join(p.file_name().ok_or_else(|| bad("invalid path"))?) + }; + if !roots.iter().any(|r| real.starts_with(r)) { + return Err(ApiError(StatusCode::FORBIDDEN, "path is outside your folders".into())); + } + Ok(real) +} + +fn is_root(app: &App, user: &User, p: &Path) -> bool { + app.user_roots(Some(&user.id)).iter().any(|r| r == p) +} + +#[derive(Deserialize)] +pub struct PathQuery { + path: String, + #[serde(default)] + hidden: bool, +} + +pub async fn list(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let dir = checked_path(&app, &user, &q.path, true)?; + if !dir.is_dir() { + return Err(bad("not a directory")); + } + let mut entries = Vec::new(); + for e in std::fs::read_dir(&dir)?.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if !q.hidden && name.starts_with('.') { + continue; + } + let meta = e.metadata().ok(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + entries.push(json!({ + "name": name, + "path": e.path(), + "is_dir": is_dir, + "size": meta.as_ref().map(|m| m.len()).unwrap_or(0), + "mtime": meta.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64), + })); + } + entries.sort_by(|a, b| { + b["is_dir"].as_bool().cmp(&a["is_dir"].as_bool()).then_with(|| a["name"].as_str().unwrap_or("").to_lowercase().cmp(&b["name"].as_str().unwrap_or("").to_lowercase())) + }); + let parent = if is_root(&app, &user, &dir) { Value::Null } else { json!(dir.parent()) }; + let repo = crate::git::repo_root(&dir); + Ok(Json(json!({"path": dir, "parent": parent, "entries": entries, "repo": repo}))) +} + +pub async fn read(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let p = checked_path(&app, &user, &q.path, true)?; + let meta = std::fs::metadata(&p)?; + if meta.is_dir() { + return Err(bad("is a directory")); + } + let mtime = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + if meta.len() > MAX_TEXT { + return Ok(Json(json!({"path": p, "binary": true, "too_large": true, "size": meta.len(), "mtime": mtime}))); + } + let bytes = tokio::fs::read(&p).await?; + let binary = bytes.iter().take(8000).any(|b| *b == 0) || std::str::from_utf8(&bytes).is_err(); + Ok(Json(json!({ + "path": p, + "binary": binary, + "size": meta.len(), + "mtime": mtime, + "content": if binary { Value::Null } else { json!(String::from_utf8_lossy(&bytes)) }, + }))) +} + +fn mime_for(p: &Path) -> &'static str { + match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", + Some("pdf") => "application/pdf", + Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +#[derive(Deserialize)] +pub struct RawQuery { + path: String, + #[serde(default)] + download: bool, +} + +/// File bytes for previews and downloads. SVGs are served as attachments only, +/// so scripts inside them never run in the app's origin. +pub async fn raw(Extension(user): Extension, State(app): State, Query(q): Query) -> Result { + let p = checked_path(&app, &user, &q.path, true)?; + if p.is_dir() { + return Err(bad("is a directory")); + } + let bytes = tokio::fs::read(&p).await?; + let mime = mime_for(&p); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("file").replace('"', ""); + let disposition = if q.download || mime == "image/svg+xml" || mime == "application/octet-stream" { + format!("attachment; filename=\"{name}\"") + } else { + format!("inline; filename=\"{name}\"") + }; + Ok(( + [ + (header::CONTENT_TYPE, mime.to_string()), + (header::CONTENT_DISPOSITION, disposition), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), + (header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()), + ], + bytes, + ) + .into_response()) +} + +#[derive(Deserialize)] +pub struct WriteBody { + path: String, + content: String, + /// mtime the client loaded; a newer file on disk is a conflict. + expected_mtime: Option, + #[serde(default)] + force: bool, +} + +pub async fn write(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let p = checked_path(&app, &user, &b.path, false)?; + if p.is_dir() { + return Err(bad("is a directory")); + } + if let (Some(expected), false) = (b.expected_mtime, b.force) { + let current = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + if current.is_some_and(|c| c > expected) { + return Err(ApiError(StatusCode::CONFLICT, "the file was changed on disk since you opened it".into())); + } + } + tokio::fs::write(&p, b.content.as_bytes()).await?; + let mtime = std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64); + Ok(Json(json!({"ok": true, "mtime": mtime}))) +} + +#[derive(Deserialize)] +pub struct CreateBody { + path: String, + #[serde(default)] + dir: bool, +} + +pub async fn create(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let p = checked_path(&app, &user, &b.path, false)?; + if p.exists() { + return Err(ApiError(StatusCode::CONFLICT, "already exists".into())); + } + if b.dir { + tokio::fs::create_dir(&p).await?; + } else { + tokio::fs::write(&p, b"").await?; + } + Ok(Json(json!({"ok": true, "path": p}))) +} + +#[derive(Deserialize)] +pub struct MoveBody { + from: String, + to: String, + #[serde(default)] + copy: bool, +} + +fn copy_recursive(from: &Path, to: &Path) -> std::io::Result<()> { + if from.is_dir() { + std::fs::create_dir_all(to)?; + for e in std::fs::read_dir(from)? { + let e = e?; + copy_recursive(&e.path(), &to.join(e.file_name()))?; + } + Ok(()) + } else { + std::fs::copy(from, to).map(|_| ()) + } +} + +pub async fn move_or_copy(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let from = checked_path(&app, &user, &b.from, true)?; + let to = checked_path(&app, &user, &b.to, false)?; + if is_root(&app, &user, &from) { + return Err(bad("a root folder cannot be moved")); + } + if to.exists() { + return Err(ApiError(StatusCode::CONFLICT, "target already exists".into())); + } + if to.starts_with(&from) { + return Err(bad("cannot move a folder into itself")); + } + let (f, t) = (from.clone(), to.clone()); + tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??; + Ok(Json(json!({"ok": true, "path": to}))) +} + +pub async fn remove(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let p = checked_path(&app, &user, &q.path, true)?; + if is_root(&app, &user, &p) { + return Err(bad("a root folder cannot be deleted")); + } + if p.is_dir() { + tokio::fs::remove_dir_all(&p).await?; + } else { + tokio::fs::remove_file(&p).await?; + } + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +pub struct UploadQuery { + /// Target file path (absolute). + path: String, +} + +/// Upload one file as the raw request body. +pub async fn upload(Extension(user): Extension, State(app): State, Query(q): Query, body: Bytes) -> ApiResult { + let p = checked_path(&app, &user, &q.path, false)?; + if p.is_dir() { + return Err(bad("is a directory")); + } + tokio::fs::write(&p, &body).await?; + Ok(Json(json!({"ok": true, "path": p, "size": body.len()}))) +} + +#[derive(Deserialize)] +pub struct SearchQuery { + path: String, + q: String, +} + +/// File name search below a folder (respects .gitignore). +pub async fn search(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let root = checked_path(&app, &user, &q.path, true)?; + let needle = q.q.to_lowercase(); + if needle.trim().is_empty() { + return Ok(Json(json!([]))); + } + let hits = tokio::task::spawn_blocking(move || { + let mut out = Vec::new(); + for e in ignore::WalkBuilder::new(&root).hidden(true).build().flatten() { + if e.depth() == 0 { + continue; + } + let name = e.file_name().to_string_lossy().to_lowercase(); + if name.contains(&needle) { + out.push(json!({"path": e.path(), "is_dir": e.file_type().is_some_and(|t| t.is_dir())})); + if out.len() >= 200 { + break; + } + } + } + out + }) + .await?; + Ok(Json(json!(hits))) +} diff --git a/server/src/git.rs b/server/src/git.rs new file mode 100644 index 0000000..d092bac --- /dev/null +++ b/server/src/git.rs @@ -0,0 +1,348 @@ +//! Git client for the file manager. Runs the git CLI without a shell; file +//! names always follow `--`, refs and URLs are validated. + +use crate::api::{ApiError, ApiResult}; +use crate::app::App; +use crate::files::checked_path; +use crate::store::User; +use axum::extract::State; +use axum::http::StatusCode; +use axum::{Extension, Json}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Command; + +type AppState = Arc; + +/// Nearest directory containing `.git`. +pub fn repo_root(dir: &Path) -> Option { + let mut d = dir.to_path_buf(); + loop { + if d.join(".git").exists() { + return Some(d); + } + if !d.pop() { + return None; + } + } +} + +struct Out { + ok: bool, + stdout: String, + stderr: String, +} + +async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result { + let fut = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_PAGER", "cat") + .env("LC_ALL", "C") + .kill_on_drop(true) + .output(); + let out = tokio::time::timeout(Duration::from_secs(timeout), fut) + .await + .map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))? + .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run git: {e}")))?; + Ok(Out { + ok: out.status.success(), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) +} + +fn fail(o: Out) -> ApiError { + let msg = if o.stderr.trim().is_empty() { o.stdout } else { o.stderr }; + ApiError(StatusCode::BAD_REQUEST, msg.trim().to_string()) +} + +async fn git_ok(dir: &Path, args: &[&str], timeout: u64) -> Result { + let o = git(dir, args, timeout).await?; + if o.ok { Ok(o.stdout) } else { Err(fail(o)) } +} + +fn valid_ref(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('-') + && !name.contains("..") + && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')) +} + +#[derive(Deserialize)] +pub struct GitBody { + /// Repository directory, or any path inside it. + path: String, + action: String, + #[serde(default)] + files: Vec, + #[serde(default)] + staged: bool, + message: Option, + #[serde(default)] + amend: bool, + branch: Option, + #[serde(default)] + create: bool, + rev: Option, + url: Option, + limit: Option, +} + +/// Repo-relative paths for the given absolute file paths (all checked). +fn rel_files(app: &App, user: &User, repo: &Path, files: &[String]) -> Result, ApiError> { + files + .iter() + .map(|f| { + let p = if Path::new(f).is_absolute() { PathBuf::from(f) } else { repo.join(f) }; + let p = checked_path(app, user, &p.to_string_lossy(), false)?; + p.strip_prefix(repo) + .map(|r| r.to_string_lossy().into_owned()) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "file is outside the repository".into())) + }) + .collect() +} + +fn parse_status(raw: &str) -> Value { + let mut branch = Value::Null; + let mut upstream = Value::Null; + let (mut ahead, mut behind) = (0, 0); + let mut files = Vec::new(); + let mut entries = raw.split('\0').filter(|s| !s.is_empty()); + while let Some(e) = entries.next() { + if let Some(h) = e.strip_prefix("## ") { + // "main...origin/main [ahead 1, behind 2]" or "No commits yet on main" + let (names, info) = h.split_once(" [").map(|(a, b)| (a, b.trim_end_matches(']'))).unwrap_or((h, "")); + let names = names.trim_start_matches("No commits yet on "); + let (b, u) = names.split_once("...").map(|(a, b)| (a, Some(b))).unwrap_or((names, None)); + branch = json!(b); + upstream = json!(u); + for part in info.split(", ") { + if let Some(n) = part.strip_prefix("ahead ") { + ahead = n.parse().unwrap_or(0); + } + if let Some(n) = part.strip_prefix("behind ") { + behind = n.parse().unwrap_or(0); + } + } + continue; + } + if e.len() < 4 { + continue; + } + let (x, y) = (&e[0..1], &e[1..2]); + let path = &e[3..]; + let mut item = json!({"path": path, "index": x, "worktree": y}); + // Renames/copies carry the original path as the next entry. + if x == "R" || x == "C" { + item["from"] = json!(entries.next()); + } + files.push(item); + } + json!({"branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind, "files": files}) +} + +pub async fn handle(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let target = checked_path(&app, &user, &b.path, b.action != "clone")?; + + // Actions that create a repository. + match b.action.as_str() { + "init" => { + if !target.is_dir() { + return Err(ApiError(StatusCode::BAD_REQUEST, "not a directory".into())); + } + let out = git_ok(&target, &["init", "-q"], 30).await?; + return Ok(Json(json!({"ok": true, "output": out}))); + } + "clone" => { + let url = b.url.as_deref().unwrap_or("").trim().to_string(); + let allowed = url.starts_with("https://") || url.starts_with("http://") || url.starts_with("ssh://") || url.starts_with("git@"); + if !allowed || url.contains(char::is_whitespace) { + return Err(ApiError(StatusCode::BAD_REQUEST, "unsupported repository URL".into())); + } + if target.exists() { + return Err(ApiError(StatusCode::CONFLICT, "target folder already exists".into())); + } + let parent = target.parent().unwrap_or(Path::new("/")).to_path_buf(); + let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("repo").to_string(); + let o = git(&parent, &["clone", "--", &url, &name], 600).await?; + if !o.ok { + return Err(fail(o)); + } + return Ok(Json(json!({"ok": true, "path": target, "output": o.stderr}))); + } + _ => {} + } + + let start = if target.is_dir() { target.clone() } else { target.parent().unwrap_or(&target).to_path_buf() }; + let repo = repo_root(&start).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "not a git repository".into()))?; + // The repository itself must be inside the user's folders. + checked_path(&app, &user, &repo.to_string_lossy(), true)?; + + let result = match b.action.as_str() { + "status" => { + let raw = git_ok(&repo, &["status", "--porcelain=v1", "-b", "-z", "--untracked-files=all"], 60).await?; + let mut v = parse_status(&raw); + v["repo"] = json!(repo); + v + } + "diff" => { + let files = rel_files(&app, &user, &repo, &b.files)?; + let mut args = vec!["diff", "--no-color", "--no-ext-diff"]; + if b.staged { + args.push("--cached"); + } + args.push("--"); + args.extend(files.iter().map(String::as_str)); + let mut out = git_ok(&repo, &args, 60).await?; + // Untracked files have no diff: show them as new. + if out.is_empty() && !b.staged && files.len() == 1 { + let o = git(&repo, &["diff", "--no-color", "--no-index", "--", "/dev/null", &files[0]], 60).await?; + out = o.stdout; + } + out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len())); + json!({"diff": out}) + } + "stage" | "unstage" | "discard" => { + let files = rel_files(&app, &user, &repo, &b.files)?; + if files.is_empty() { + return Err(ApiError(StatusCode::BAD_REQUEST, "no files".into())); + } + let mut args: Vec<&str> = match b.action.as_str() { + "stage" => vec!["add", "-A"], + "unstage" => vec!["restore", "--staged"], + _ => vec!["restore", "--worktree"], + }; + args.push("--"); + args.extend(files.iter().map(String::as_str)); + git_ok(&repo, &args, 60).await?; + json!({"ok": true}) + } + "commit" => { + let msg = b.message.as_deref().unwrap_or("").trim().to_string(); + if msg.is_empty() && !b.amend { + return Err(ApiError(StatusCode::BAD_REQUEST, "commit message is empty".into())); + } + // Use the repository's identity if configured, otherwise the CodeCraft user. + let has_identity = git(&repo, &["config", "user.email"], 10).await.map(|o| o.ok && !o.stdout.trim().is_empty()).unwrap_or(false); + let name = format!("user.name={}", if user.display_name.is_empty() { &user.username } else { &user.display_name }); + let email = format!("user.email={}@users.codecraft.local", user.username); + let mut args: Vec<&str> = Vec::new(); + if !has_identity { + args.extend(["-c", &name, "-c", &email]); + } + args.push("commit"); + if b.amend { + args.push("--amend"); + if msg.is_empty() { + args.push("--no-edit"); + } + } + if !msg.is_empty() { + args.extend(["-m", &msg]); + } + let out = git_ok(&repo, &args, 60).await?; + json!({"ok": true, "output": out}) + } + "log" => { + let n = b.limit.unwrap_or(50).min(500).to_string(); + let raw = git(&repo, &["log", "-n", &n, "--pretty=format:%H%x1f%an%x1f%at%x1f%s%x1f%D%x1e"], 60).await?; + let commits: Vec = raw + .stdout + .split('\u{1e}') + .filter(|s| !s.trim().is_empty()) + .map(|c| { + let f: Vec<&str> = c.trim_start_matches('\n').split('\u{1f}').collect(); + json!({ + "hash": f.first(), "author": f.get(1), + "time": f.get(2).and_then(|t| t.parse::().ok()).map(|t| t * 1000), + "subject": f.get(3), "refs": f.get(4), + }) + }) + .collect(); + json!({"commits": commits}) + } + "show" => { + let rev = b.rev.as_deref().unwrap_or(""); + if rev.len() < 4 || !rev.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid commit".into())); + } + let mut out = git_ok(&repo, &["show", "--no-color", "--stat", "--patch", rev], 60).await?; + out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len())); + json!({"diff": out}) + } + "branches" => { + let raw = git_ok(&repo, &["branch", "-a", "--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)"], 30).await?; + let list: Vec = raw + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let f: Vec<&str> = l.split('\u{1f}').collect(); + json!({"name": f.first(), "current": f.get(1) == Some(&"*"), "upstream": f.get(2).filter(|u| !u.is_empty())}) + }) + .collect(); + json!({"branches": list}) + } + "checkout" => { + let branch = b.branch.as_deref().unwrap_or(""); + if !valid_ref(branch) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid branch name".into())); + } + let args: Vec<&str> = if b.create { vec!["switch", "-c", branch] } else { vec!["switch", branch] }; + let o = git(&repo, &args, 60).await?; + if !o.ok { + return Err(fail(o)); + } + json!({"ok": true, "output": o.stderr}) + } + "fetch" | "pull" | "push" => { + let args: Vec<&str> = match b.action.as_str() { + "fetch" => vec!["fetch", "--prune"], + "pull" => vec!["pull", "--ff-only"], + _ => { + let has_upstream = git(&repo, &["rev-parse", "--abbrev-ref", "@{u}"], 10).await.map(|o| o.ok).unwrap_or(false); + if has_upstream { vec!["push"] } else { vec!["push", "-u", "origin", "HEAD"] } + } + }; + let o = git(&repo, &args, 300).await?; + if !o.ok { + return Err(fail(o)); + } + json!({"ok": true, "output": format!("{}{}", o.stdout, o.stderr)}) + } + other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown action '{other}'"))), + }; + Ok(Json(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_porcelain_status() { + let raw = "## main...origin/main [ahead 2, behind 1]\0 M src/a.rs\0A new.txt\0R b.rs\0old.rs\0?? x y.txt\0"; + let v = parse_status(raw); + assert_eq!(v["branch"], "main"); + assert_eq!(v["upstream"], "origin/main"); + assert_eq!(v["ahead"], 2); + assert_eq!(v["behind"], 1); + let files = v["files"].as_array().unwrap(); + assert_eq!(files.len(), 4); + assert_eq!(files[2]["from"], "old.rs"); + assert_eq!(files[3]["path"], "x y.txt"); + assert_eq!(parse_status("## No commits yet on main\0")["branch"], "main"); + } + + #[test] + fn refs_are_validated() { + assert!(valid_ref("feature/login-2")); + assert!(!valid_ref("--upload-pack=x")); + assert!(!valid_ref("a..b")); + assert!(!valid_ref("a b")); + } +} diff --git a/server/src/images.rs b/server/src/images.rs new file mode 100644 index 0000000..31670a8 --- /dev/null +++ b/server/src/images.rs @@ -0,0 +1,237 @@ +//! Image generation via fal.ai – as an agent tool and for the manual image page. + +use crate::app::App; +use crate::config::{ImageModel, expand_env}; +use crate::store::{ImageRecord, UsageRecord}; +use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str}; +use crate::types::{ImageData, new_id, now_ms}; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; +use base64::Engine; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Size presets offered in the UI and to the model (FLUX-style names). +pub const SIZES: &[&str] = &["square_hd", "square", "landscape_4_3", "landscape_16_9", "portrait_4_3", "portrait_16_9"]; + +fn aspect_ratio(size: &str) -> &'static str { + match size { + "landscape_4_3" => "4:3", + "landscape_16_9" => "16:9", + "portrait_4_3" => "3:4", + "portrait_16_9" => "9:16", + _ => "1:1", + } +} + +pub struct GenerateRequest { + pub prompt: String, + pub model: Option, + pub size: Option, + pub count: u32, + /// Directory the files are written to. + pub dir: PathBuf, + pub user_id: Option, + pub session_id: Option, +} + +fn slug(s: &str) -> String { + let mut out: String = s + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect(); + while out.contains("--") { + out = out.replace("--", "-"); + } + out.trim_matches('-').chars().take(40).collect::().trim_end_matches('-').to_string() +} + +fn model_config(app: &App, id: &str) -> ImageModel { + app.config() + .images + .models + .iter() + .find(|m| m.id == id) + .cloned() + .unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() }) +} + +async fn download(url: &str) -> Result<(Vec, String)> { + if let Some(rest) = url.strip_prefix("data:") { + let (meta, data) = rest.split_once(',').context("malformed data URI")?; + let ctype = meta.split(';').next().unwrap_or("image/png").to_string(); + return Ok((base64::engine::general_purpose::STANDARD.decode(data)?, ctype)); + } + let resp = crate::provider::http_client().get(url).timeout(Duration::from_secs(120)).send().await?.error_for_status()?; + let ctype = resp.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("image/png").to_string(); + Ok((resp.bytes().await?.to_vec(), ctype)) +} + +/// Generate images, store them in `req.dir` and record them (history + cost). +pub async fn generate(app: &App, req: GenerateRequest) -> Result> { + let cfg = app.config(); + let ic = &cfg.images; + if !ic.enabled { + bail!("image generation is disabled – enable [images] in the settings"); + } + let key = expand_env(&ic.api_key); + if key.is_empty() { + bail!("no fal.ai API key configured (images.api_key, e.g. ${{FAL_KEY}})"); + } + let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone()); + let model = model_config(app, &model_id); + let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into()); + let count = req.count.clamp(1, 4); + + let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"}); + match model.size_param.as_deref() { + Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)), + Some("none") => {} + _ => body["image_size"] = json!(size), + } + for (k, v) in &model.options { + body[k] = v.clone(); + } + + let url = format!("{}/{}", expand_env(&ic.base_url).trim_end_matches('/'), model_id.trim_matches('/')); + let resp = crate::provider::http_client() + .post(&url) + .header("Authorization", format!("Key {key}")) + .timeout(Duration::from_secs(300)) + .json(&body) + .send() + .await + .context("fal.ai request failed")?; + if !resp.status().is_success() { + return Err(crate::provider::http_error(resp).await); + } + let out: Value = resp.json().await?; + let images = out["images"].as_array().cloned().or_else(|| out["image"].as_object().map(|o| vec![Value::Object(o.clone())])).unwrap_or_default(); + if images.is_empty() { + bail!("fal.ai returned no images: {}", out.to_string().chars().take(300).collect::()); + } + + tokio::fs::create_dir_all(&req.dir).await?; + let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let base = slug(&req.prompt); + let mut records = Vec::new(); + for (i, img) in images.iter().enumerate() { + let src = img["url"].as_str().context("image without url")?; + let (bytes, ctype) = download(src).await?; + let ext = match ctype.as_str() { + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => "png", + }; + let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") }; + let path = req.dir.join(name); + tokio::fs::write(&path, &bytes).await?; + let rec = ImageRecord { + id: new_id(), + user_id: req.user_id.clone(), + session_id: req.session_id.clone(), + prompt: req.prompt.clone(), + model: model_id.clone(), + path: path.display().to_string(), + width: img["width"].as_i64().unwrap_or(0), + height: img["height"].as_i64().unwrap_or(0), + created_at: now_ms(), + }; + app.store.add_image(&rec)?; + records.push(rec); + } + + // Costs go into the normal statistics (provider "fal"). + let cost = model.price.unwrap_or(0.0) * records.len() as f64; + let session = req.session_id.clone().unwrap_or_default(); + let _ = app.store.add_usage(&UsageRecord { + ts: now_ms(), + session_id: session.clone(), + root_session_id: if session.is_empty() { String::new() } else { app.root_session_id(&session) }, + provider: "fal".into(), + model: model_id, + agent: "image".into(), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + ttft_ms: 0, + duration_ms: 0, + cost, + error: false, + }); + Ok(records) +} + +/// Downscaled copy for the model's context. +fn preview_for_model(path: &Path) -> Option { + let img = image::open(path).ok()?; + let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle); + let mut buf = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?; + Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) }) +} + +pub struct GenerateImage; + +#[async_trait] +impl Tool for GenerateImage { + fn name(&self) -> &str { + "generate_image" + } + fn description(&self) -> String { + "Generate images from a text prompt (fal.ai) and save them in the project. Use for \ + illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \ + visual prompt in English (subject, style, composition, colours, lighting). Each image \ + costs money – generate only what is needed. Returns the file paths and a preview." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "size": {"type": "string", "enum": SIZES}, + "count": {"type": "integer", "minimum": 1, "maximum": 4}, + "directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"}, + "model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"} + }, + "required": ["prompt"] + }) + } + fn title(&self, input: &Value) -> String { + format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::()) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let prompt = arg_str(&input, "prompt")?.to_string(); + let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone()); + let dir = ctx.resolve(&dir_arg)?; + let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id); + let records = generate( + &ctx.app, + GenerateRequest { + prompt, + model: input["model"].as_str().map(String::from), + size: input["size"].as_str().map(String::from), + count: input["count"].as_u64().unwrap_or(1) as u32, + dir, + user_id: owner, + session_id: Some(ctx.session_id.clone()), + }, + ) + .await + .map_err(|e| anyhow!("{e:#}"))?; + let mut text = String::from("Generated:\n"); + let mut images = Vec::new(); + for r in &records { + let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone()); + text.push_str(&format!("- {rel} ({}×{})\n", r.width, r.height)); + if let Some(p) = preview_for_model(Path::new(&r.path)) { + images.push(p); + } + } + let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect(); + Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() }) + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 079386f..7d91bc3 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -2,7 +2,12 @@ mod api; mod app; +mod auth; +mod budget; mod config; +mod files; +mod git; +mod images; mod mcp; mod permission; mod prompts; @@ -30,9 +35,6 @@ async fn main() -> Result<()> { let (cfg, _) = config::load_or_init(&path)?; tracing::info!("config: {}", path.display()); tracing::info!("workspace: {}", cfg.workspace().display()); - if cfg.auth_token().is_empty() { - tracing::warn!("auth_token is empty – the API is unprotected. Only use this on localhost."); - } let bind = cfg.server.bind.clone(); let app = app::App::new(path, cfg).await?; let router = api::router(app); diff --git a/server/src/mcp.rs b/server/src/mcp.rs index 03c419c..a9b730e 100644 --- a/server/src/mcp.rs +++ b/server/src/mcp.rs @@ -30,6 +30,9 @@ pub struct McpToolInfo { pub name: String, pub description: String, pub input_schema: Value, + /// The server marks the tool as read-only (annotations.readOnlyHint). + #[serde(default)] + pub read_only: bool, } #[derive(Debug, Clone, Serialize)] @@ -183,6 +186,7 @@ impl McpServer { name: t["name"].as_str().unwrap_or_default().to_string(), description: t["description"].as_str().unwrap_or_default().to_string(), input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})), + read_only: t["annotations"]["readOnlyHint"].as_bool().unwrap_or(false), }); } cursor = res["nextCursor"].as_str().map(String::from); @@ -498,20 +502,147 @@ impl McpManager { } } - pub fn tools(&self) -> Vec> { + /// All MCP tools, split into tools given to the model directly and tools + /// that are only reachable through mcp_search / mcp_call. + pub fn tools(&self, cfg: &crate::config::Config) -> (Vec>, Vec>) { let servers = self.servers.lock().unwrap(); - let mut out: Vec> = Vec::new(); + let mut direct: Vec> = Vec::new(); + let mut deferred = Vec::new(); for (sname, server) in servers.iter() { - for t in server.tools.lock().unwrap().iter() { - out.push(Arc::new(McpTool { - full_name: tool_name(sname, &t.name), - server: server.clone(), - info: t.clone(), - })); + let tools = server.tools.lock().unwrap(); + let load = cfg.mcp.get(sname).and_then(|m| m.load.clone()).unwrap_or_else(|| "auto".into()); + let on_demand = match load.as_str() { + "always" => false, + "on_demand" => true, + _ => tools.len() > cfg.server.mcp_direct_limit, + }; + for t in tools.iter() { + let tool = Arc::new(McpTool { full_name: tool_name(sname, &t.name), server: server.clone(), info: t.clone() }); + if on_demand { + deferred.push(tool); + } else { + direct.push(tool); + } } } - out + (direct, deferred) } + + /// Short overview of on-demand servers for the system prompt. + pub fn deferred_summary(&self, cfg: &crate::config::Config) -> Vec<(String, usize, Vec)> { + let (_, deferred) = self.tools(cfg); + let mut by_server: BTreeMap> = BTreeMap::new(); + for t in deferred { + by_server.entry(t.server.name.clone()).or_default().push(t.info.name.clone()); + } + by_server + .into_iter() + .map(|(s, names)| { + let n = names.len(); + (s, n, names.into_iter().take(12).collect()) + }) + .collect() + } +} + +/// Search the on-demand MCP tools by keywords. +pub struct McpSearch; + +#[async_trait] +impl Tool for McpSearch { + fn name(&self) -> &str { + "mcp_search" + } + fn description(&self) -> String { + "Find tools of the on-demand MCP servers listed in the system prompt. Returns matching tools \ + with their input schema; call them with mcp_call. Search with a few keywords (e.g. \ + \"container restart\", \"light living room\")." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Keywords"}, + "server": {"type": "string", "description": "Only search this server (optional)"} + }, + "required": ["query"] + }) + } + fn read_only(&self) -> bool { + true + } + fn title(&self, input: &Value) -> String { + format!("Search MCP tools: {}", input["query"].as_str().unwrap_or("")) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let query = input["query"].as_str().unwrap_or("").to_lowercase(); + let server = input["server"].as_str(); + let words: Vec<&str> = query.split_whitespace().collect(); + let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config()); + let mut scored: Vec<(usize, &Arc)> = deferred + .iter() + .filter(|t| server.is_none_or(|s| t.server.name == s)) + .map(|t| { + let name = t.info.name.to_lowercase(); + let desc = t.info.description.to_lowercase(); + let score: usize = words.iter().map(|w| if name.contains(w) { 3 } else if desc.contains(w) { 1 } else { 0 }).sum(); + (score, t) + }) + .filter(|(s, _)| *s > 0 || words.is_empty()) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0)); + if scored.is_empty() { + return Ok(ToolOutput::text("No matching tools. Try other keywords or another server.")); + } + let mut out = String::new(); + for (_, t) in scored.iter().take(8) { + let desc: String = t.info.description.chars().take(300).collect(); + out.push_str(&format!("## {}\n{}\nInput schema: {}\n\n", t.full_name, desc, t.info.input_schema)); + } + if scored.len() > 8 { + out.push_str(&format!("({} more matches – refine the query)\n", scored.len() - 8)); + } + Ok(ToolOutput::text(out)) + } +} + +/// Call an on-demand MCP tool found with mcp_search. Permissions are checked +/// against the target tool by the runner. +pub struct McpCall; + +#[async_trait] +impl Tool for McpCall { + fn name(&self) -> &str { + "mcp_call" + } + fn description(&self) -> String { + "Call an MCP tool found with mcp_search, by its full name (mcp____).".into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "tool": {"type": "string", "description": "Full tool name, e.g. mcp__dockhand__list_containers"}, + "arguments": {"type": "object", "description": "Arguments matching the tool's input schema"} + }, + "required": ["tool"] + }) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let target = resolve_call_target(ctx, &input)?; + target.run(input.get("arguments").cloned().unwrap_or(json!({})), ctx).await + } +} + +/// Find the MCP tool an mcp_call refers to. +pub fn resolve_call_target(ctx: &ToolCtx, input: &Value) -> Result> { + let name = input["tool"].as_str().unwrap_or(""); + let (_, deferred) = ctx.app.mcp.tools(&ctx.app.config()); + deferred + .into_iter() + .find(|t| t.full_name == name) + .ok_or_else(|| anyhow!("unknown MCP tool '{name}' – use mcp_search to find tool names")) } /// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers. @@ -523,7 +654,7 @@ pub fn tool_name(server: &str, tool: &str) -> String { } pub struct McpTool { - full_name: String, + pub full_name: String, server: Arc, info: McpToolInfo, } @@ -546,6 +677,9 @@ impl Tool for McpTool { fn title(&self, _input: &Value) -> String { format!("{} → {}", self.server.name, self.info.name) } + fn read_only(&self) -> bool { + self.info.read_only + } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await } diff --git a/server/src/permission.rs b/server/src/permission.rs index 82172d6..238b00e 100644 --- a/server/src/permission.rs +++ b/server/src/permission.rs @@ -81,6 +81,70 @@ pub fn bash_prefix(cmd: &str) -> String { cmd.split_whitespace().take(2).collect::>().join(" ") } +/// Session permission modes. +pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"]; + +/// Commands that only read. Checked per simple command (after splitting). +const SAFE_COMMANDS: &[&str] = &[ + "ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat", + "du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename", + "dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps", + "jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more", +]; + +const SAFE_GIT: &[&str] = &[ + "status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote", +]; + +/// Strip harmless redirections so they don't count as file writes. +fn strip_redirections(cmd: &str) -> String { + cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "") +} + +/// A simple command that only reads. +pub fn is_safe_command(part: &str) -> bool { + let cleaned = strip_redirections(part); + if cleaned.contains('>') { + return false; + } + let words: Vec = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect()); + // Leading VAR=value assignments are not safe (could change behaviour); keep it strict. + let Some(cmd) = words.first() else { return true }; + let args: Vec<&str> = words[1..].iter().map(String::as_str).collect(); + if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") { + return true; + } + match cmd.as_str() { + c if SAFE_COMMANDS.contains(&c) => true, + "find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")), + "sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")), + "git" => match args.first() { + Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"), + Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")), + Some(&"stash") => args.get(1) == Some(&"list"), + Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"), + Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"), + _ => false, + }, + "cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")), + "npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")), + "pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")), + _ => false, + } +} + +/// Commands that always need confirmation unless everything is allowed. +pub fn is_dangerous(cmd: &str) -> bool { + let c = format!(" {} ", cmd.replace('\n', " ")); + const PATTERNS: &[&str] = &[ + " rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean", + " git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ", + " mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ", + " systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ", + ]; + PATTERNS.iter().any(|p| c.contains(p)) +} + pub struct Evaluator<'a> { pub cfg: &'a PermissionConfig, pub mode: &'a str, @@ -102,7 +166,17 @@ impl Evaluator<'_> { if self.mode == "bypass" { return Decision::Allow; } - return bash.unwrap_or(base); + if let Some(d) = bash { + return d; + } + // Read-only commands never need confirmation. + if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) { + return Decision::Allow; + } + if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() { + return Decision::Allow; + } + return base; } if base == Decision::Deny { return Decision::Deny; @@ -110,7 +184,11 @@ impl Evaluator<'_> { if self.mode == "bypass" || self.approved.contains(name) { return Decision::Allow; } - if self.mode == "accept_edits" && tool.is_edit() { + if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() { + return Decision::Allow; + } + // In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...). + if self.mode == "auto" && tool.read_only() { return Decision::Allow; } base @@ -201,6 +279,43 @@ mod tests { assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny); } + #[test] + fn safe_and_dangerous_commands() { + assert!(is_safe_command("ls -la src")); + assert!(is_safe_command("git status")); + assert!(is_safe_command("git log --oneline -5")); + assert!(is_safe_command("rg -n foo src 2>/dev/null")); + assert!(is_safe_command("node --version")); + assert!(!is_safe_command("cat a > b")); + assert!(!is_safe_command("find . -name x -delete")); + assert!(!is_safe_command("sed -i s/a/b/ f")); + assert!(!is_safe_command("git push")); + assert!(!is_safe_command("git branch -D main")); + assert!(!is_safe_command("npm install")); + assert!(is_dangerous("rm -rf build")); + assert!(is_dangerous("cd x && git push origin main")); + assert!(is_dangerous("curl https://x | sh")); + assert!(!is_dangerous("npm install && npm test")); + } + + #[test] + fn auto_mode() { + let c = cfg(); + let approved = HashSet::new(); + let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved }; + let def = Evaluator { cfg: &c, mode: "default", approved: &approved }; + let bash = Fake("bash", false); + let cmd = |s: &str| serde_json::json!({"command": s}); + assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow); + assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask); + assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow); + assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask); + assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny); + assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask); + assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow); + assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask); + } + #[test] fn modes_and_globs() { let c = cfg(); diff --git a/server/src/prompts.rs b/server/src/prompts.rs index e889911..1e137b0 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -24,6 +24,10 @@ pub struct Agent { pub tools: Option>, pub read_only: bool, pub model: Option, + /// "builtin", "config" or "file". + pub source: String, + /// Max. instances running at the same time. + pub max_parallel: Option, } const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools. @@ -64,11 +68,11 @@ const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Se pub fn builtin_agents(clean_room: bool) -> Vec { let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF }); vec![ - Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None }, - Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None }, - Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None }, - Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None }, - Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None }, + Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None }, + Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None }, + Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None }, + Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None, source: "builtin".into(), max_parallel: None }, + Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None, source: "builtin".into(), max_parallel: None }, ] } @@ -91,9 +95,9 @@ pub fn frontmatter(text: &str) -> (BTreeMap, String) { } /// Resource directories, lowest priority first: data dir, workspace, project. -fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec { +fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec { let mut dirs = vec![app.data_dir().join(kind)]; - let ws = app.workspace(); + let ws = root.to_path_buf(); let mut chain = vec![ws.clone()]; if cwd != ws { chain.push(cwd.to_path_buf()); @@ -105,12 +109,12 @@ fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec { dirs } -pub fn agents(app: &App, cwd: &Path) -> Vec { +pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { let cfg = app.config(); let mut map: BTreeMap = builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect(); // Markdown agents (Claude Code format). - for dir in resource_dirs(app, cwd, "agents") { + for dir in resource_dirs(app, root, cwd, "agents") { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for e in entries.flatten() { let p = e.path(); @@ -129,6 +133,8 @@ pub fn agents(app: &App, cwd: &Path) -> Vec { tools, read_only: false, model: meta.get("model").filter(|m| m.contains('/')).cloned(), + source: "file".into(), + max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), }); } } @@ -142,23 +148,25 @@ pub fn agents(app: &App, cwd: &Path) -> Vec { prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(), tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())), read_only: base.as_ref().is_some_and(|b| b.read_only), - model: a.model.clone().or(base.and_then(|b| b.model)), + model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())), + source: "config".into(), + max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)), }); } map.into_values().collect() } -pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent { - agents(app, cwd) +pub fn find_agent(app: &App, root: &Path, cwd: &Path, name: &str) -> Agent { + agents(app, root, cwd) .into_iter() .find(|a| a.name == name) .unwrap_or_else(|| builtin_agents(true).remove(0)) } /// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd. -fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> { +fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> { let mut dirs = vec![app.data_dir()]; - let ws = app.workspace(); + let ws = root.to_path_buf(); let mut d = cwd.to_path_buf(); let mut chain = Vec::new(); loop { @@ -183,28 +191,49 @@ fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> { out } -pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String { +pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str) -> String { let mut s = String::from(BASE_PROMPT); s.push_str("\n\n"); s.push_str(&agent.prompt); + if permission_mode == "auto" && !agent.read_only { + s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically."); + } let git = cwd.join(".git").exists(); s.push_str(&format!( - "\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n", + "\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n", cwd.display(), - app.workspace().display(), + root.display(), if git { "yes" } else { "no" }, std::env::consts::OS, std::env::consts::ARCH, chrono::Local::now().format("%Y-%m-%d"), )); - let skills = skills(app, cwd); + if !agent.read_only || agent.name == "plan" { + let subs: Vec = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect(); + if !subs.is_empty() { + s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n"); + for a in &subs { + let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default(); + let ro = if a.read_only { " (read-only)" } else { "" }; + s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description)); + } + } + } + let deferred = app.mcp.deferred_summary(&app.config()); + if !deferred.is_empty() { + s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n"); + for (server, n, names) in &deferred { + s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", "))); + } + } + let skills = skills(app, root, cwd); if !skills.is_empty() { s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n"); for sk in &skills { s.push_str(&format!("- {}: {}\n", sk.name, sk.description)); } } - let files = instruction_files(app, cwd); + let files = instruction_files(app, root, cwd); if !files.is_empty() { s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n"); for (p, text) in files { @@ -226,9 +255,9 @@ pub struct Command { pub agent: Option, } -pub fn commands(app: &App, cwd: &Path) -> Vec { +pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec { let mut map = BTreeMap::new(); - for dir in resource_dirs(app, cwd, "commands") { + for dir in resource_dirs(app, root, cwd, "commands") { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for e in entries.flatten() { let p = e.path(); @@ -249,10 +278,10 @@ pub fn commands(app: &App, cwd: &Path) -> Vec { } /// Expand "/name args" into the command's prompt. Returns None if not a known command. -pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option)> { +pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option)> { let rest = text.strip_prefix('/')?; let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?; + let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?; let args = args.trim(); let mut body = cmd.body.replace("$ARGUMENTS", args); for (i, a) in args.split_whitespace().enumerate() { @@ -274,9 +303,9 @@ pub struct Skill { pub path: PathBuf, } -pub fn skills(app: &App, cwd: &Path) -> Vec { +pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec { let mut map = BTreeMap::new(); - for dir in resource_dirs(app, cwd, "skills") { + for dir in resource_dirs(app, root, cwd, "skills") { let Ok(entries) = std::fs::read_dir(&dir) else { continue }; for e in entries.flatten() { let p = e.path().join("SKILL.md"); @@ -307,7 +336,7 @@ impl Tool for SkillTool { } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { let name = arg_str(&input, "name")?; - let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else { + let Some(sk) = skills(&ctx.app, &ctx.root(), &ctx.cwd).into_iter().find(|s| s.name == name) else { return Ok(ToolOutput::error(format!("unknown skill '{name}'"))); }; let text = std::fs::read_to_string(&sk.path)?; diff --git a/server/src/provider/anthropic.rs b/server/src/provider/anthropic.rs index 2f396d3..3b6f297 100644 --- a/server/src/provider/anthropic.rs +++ b/server/src/provider/anthropic.rs @@ -165,6 +165,9 @@ impl Provider for Anthropic { body["output_config"] = json!({"effort": effort}); } + for (k, v) in &req.model.options { + body[k] = v.clone(); + } let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?; if !resp.status().is_success() { return Err(http_error(resp).await); diff --git a/server/src/provider/mod.rs b/server/src/provider/mod.rs index 1d31472..4964e5b 100644 --- a/server/src/provider/mod.rs +++ b/server/src/provider/mod.rs @@ -105,6 +105,13 @@ pub struct ProviderEntry { #[derive(Default)] pub struct Registry { pub providers: BTreeMap, + /// Concurrency limits keyed by "provider" and "provider/model". + limits: BTreeMap>, +} + +/// Held while a request runs; dropping it frees the slot(s). +pub struct Slot { + _permits: Vec, } pub fn http_client() -> reqwest::Client { @@ -121,6 +128,7 @@ impl Registry { pub async fn build(cfg: &Config) -> Self { let client = http_client(); let mut providers = BTreeMap::new(); + let mut limits = BTreeMap::new(); for (name, pc) in &cfg.providers { if !pc.enabled { continue; @@ -131,6 +139,14 @@ impl Registry { ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)), }; let mut models: Vec = pc.models.iter().map(|m| m.resolved()).collect(); + if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) { + limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n))); + } + for m in &models { + if let Some(n) = m.max_concurrency.filter(|n| *n > 0) { + limits.insert(format!("{name}/{}", m.id), Arc::new(tokio::sync::Semaphore::new(n))); + } + } if models.is_empty() { match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await { Ok(Ok(m)) => models = m, @@ -143,7 +159,28 @@ impl Registry { ProviderEntry { name: name.clone(), provider, models }, ); } - Self { providers } + Self { providers, limits } + } + + /// Wait for a free slot on the model and its provider. Always acquired in the + /// same order (model, then provider), so concurrent requests cannot deadlock. + pub async fn acquire(&self, spec: &str) -> Slot { + let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec); + let mut permits = Vec::new(); + for key in [spec, provider] { + if let Some(sem) = self.limits.get(key) { + if let Ok(p) = sem.clone().acquire_owned().await { + permits.push(p); + } + } + } + Slot { _permits: permits } + } + + /// True if a request to this model would have to wait. + pub fn is_saturated(&self, spec: &str) -> bool { + let provider = spec.split_once('/').map(|(p, _)| p).unwrap_or(spec); + [spec, provider].iter().any(|k| self.limits.get(*k).is_some_and(|s| s.available_permits() == 0)) } /// Resolve "provider/model". Unknown models of a known provider are allowed diff --git a/server/src/provider/openai.rs b/server/src/provider/openai.rs index 6be9fb4..364dbb1 100644 --- a/server/src/provider/openai.rs +++ b/server/src/provider/openai.rs @@ -107,6 +107,55 @@ pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec { out } +/// Splits streamed content into text and inline `…` reasoning. +/// Tags may be split across chunks, so a possible partial tag is held back. +#[derive(Default)] +pub struct ThinkSplitter { + in_think: bool, + pending: String, +} + +const OPEN: &str = ""; +const CLOSE: &str = ""; + +impl ThinkSplitter { + /// Returns (is_reasoning, text) pieces that are safe to emit. + pub fn push(&mut self, chunk: &str) -> Vec<(bool, String)> { + self.pending.push_str(chunk); + let mut out = Vec::new(); + loop { + let tag = if self.in_think { CLOSE } else { OPEN }; + if let Some(pos) = self.pending.find(tag) { + let before: String = self.pending[..pos].to_string(); + if !before.is_empty() { + out.push((self.in_think, before)); + } + self.pending.drain(..pos + tag.len()); + self.in_think = !self.in_think; + continue; + } + // Keep a trailing prefix of the tag (e.g. " 0 { + let text: String = self.pending.drain(..emit_len).collect(); + out.push((self.in_think, text)); + } + break; + } + // Leading whitespace after is noise. + out.retain(|(_, t)| !t.is_empty()); + out + } + + pub fn finish(&mut self) -> Vec<(bool, String)> { + if self.pending.is_empty() { + return vec![]; + } + vec![(self.in_think, std::mem::take(&mut self.pending))] + } +} + #[derive(Default)] struct PendingCall { id: String, @@ -145,6 +194,9 @@ impl Provider for OpenAi { body["reasoning_effort"] = json!(effort); } + for (k, v) in &req.model.options { + body[k] = v.clone(); + } let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body); let resp = self.ep.apply(rb).send().await?; if !resp.status().is_success() { @@ -153,6 +205,7 @@ impl Provider for OpenAi { let mut events = resp.bytes_stream().eventsource(); let mut calls: BTreeMap = BTreeMap::new(); + let mut think = ThinkSplitter::default(); let mut usage = Usage::default(); let mut stop_reason = String::from("stop"); @@ -182,7 +235,11 @@ impl Provider for OpenAi { let Some(choice) = data["choices"].get(0) else { continue }; let d = &choice["delta"]; if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) { - tx.send(StreamEvent::TextDelta(t.into())).await.ok(); + // Models served by llama.cpp & co. may inline their reasoning as …. + for (is_reasoning, chunk) in think.push(t) { + let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) }; + tx.send(ev).await.ok(); + } } let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str()); if let Some(r) = r.filter(|r| !r.is_empty()) { @@ -211,6 +268,10 @@ impl Provider for OpenAi { } } + for (is_reasoning, chunk) in think.finish() { + let ev = if is_reasoning { StreamEvent::ReasoningDelta(chunk) } else { StreamEvent::TextDelta(chunk) }; + tx.send(ev).await.ok(); + } for (i, pc) in calls { let input = if pc.args.trim().is_empty() { json!({}) @@ -249,3 +310,34 @@ impl Provider for OpenAi { Ok(models) } } + +#[cfg(test)] +mod tests { + use super::ThinkSplitter; + + fn run(chunks: &[&str]) -> (String, String) { + let mut s = ThinkSplitter::default(); + let mut text = String::new(); + let mut reasoning = String::new(); + let mut parts: Vec<(bool, String)> = chunks.iter().flat_map(|c| s.push(c)).collect(); + parts.extend(s.finish()); + for (r, t) in parts { + if r { reasoning.push_str(&t) } else { text.push_str(&t) } + } + (text, reasoning) + } + + #[test] + fn splits_think_tags_across_chunks() { + let (text, reasoning) = run(&["plan ", "the fixHere is ", "the answer."]); + assert_eq!(reasoning, "plan the fix"); + assert_eq!(text, "Here is the answer."); + } + + #[test] + fn plain_text_and_lookalikes_pass_through() { + let (text, reasoning) = run(&["a < b and stays"]); + assert_eq!(text, "a < b and stays"); + assert!(reasoning.is_empty()); + } +} diff --git a/server/src/runner.rs b/server/src/runner.rs index aac31ef..12bc65b 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -20,30 +20,55 @@ use tokio_util::sync::CancellationToken; const MAX_STEPS: usize = 250; const MAX_SUBAGENT_DEPTH: usize = 3; -/// Start processing a user prompt. If the session is busy the prompt is queued -/// and injected at the next step. +/// What happened to a prompt. +pub enum Submitted { + Started, + Queued(crate::store::QueueItem), +} + +fn emit_queue(app: &App, session_id: &str) { + let items = app.store.queue_list(session_id).unwrap_or_default(); + app.emit(Event::QueueUpdated { session_id: session_id.into(), items: serde_json::json!(items) }); +} + +/// Take all queued messages (one user message's worth of parts). +fn take_queue(app: &App, session_id: &str) -> Vec { + let items = app.store.queue_take(session_id).unwrap_or_default(); + if !items.is_empty() { + emit_queue(app, session_id); + } + items.into_iter().flat_map(|i| i.parts).collect() +} + +/// Start processing a user prompt. If the session is busy, the prompt (with +/// attachments) is queued and delivered at the agent's next step. pub fn start_prompt(app: Arc, session_id: String, parts: Vec) -> Result<()> { + submit(app, session_id, parts, "user").map(|_| ()) +} + +pub fn submit(app: Arc, session_id: String, parts: Vec, source: &str) -> Result { + // Queueing and the end-of-run check run under the same runtime lock, so a + // message is never lost or delivered twice. + let cancel = CancellationToken::new(); + let queued = app.with_runtime(&session_id, |r| -> Result> { + if r.cancel.is_some() { + let item = crate::store::QueueItem { id: new_id(), parts: parts.clone(), source: source.into(), created_at: now_ms() }; + app.store.queue_add(&session_id, &item)?; + Ok(Some(item)) + } else { + r.cancel = Some(cancel.clone()); + Ok(None) + } + })?; + if let Some(item) = queued { + emit_queue(&app, &session_id); + return Ok(Submitted::Queued(item)); + } let text_only: String = parts .iter() .filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }) .collect::>() .join("\n"); - let queued = app.with_runtime(&session_id, |r| { - if r.cancel.is_some() && parts.iter().all(|p| matches!(p, Part::Text { .. })) { - r.queue.push(text_only.clone()); - true - } else { - false - } - }); - if queued { - return Ok(()); - } - if app.is_busy(&session_id) { - bail!("session is busy"); - } - let cancel = CancellationToken::new(); - app.with_runtime(&session_id, |r| r.cancel = Some(cancel.clone())); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); tokio::spawn(async move { @@ -59,17 +84,31 @@ pub fn start_prompt(app: Arc, session_id: String, parts: Vec) -> Resu tracing::warn!("session {session_id}: {e:#}"); app.emit(Event::Error { session_id: session_id.clone(), message: format!("{e:#}") }); } - // Prompts queued after the last step start a new turn. + // Messages queued after the last step start a new turn – unless the user + // stopped the agent: then they stay queued until sent explicitly. let leftover = app.with_runtime(&session_id, |r| { r.cancel = None; - std::mem::take(&mut r.queue) + if cancel.is_cancelled() { Vec::new() } else { take_queue(&app, &session_id) } }); app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); - if !leftover.is_empty() && !cancel.is_cancelled() { - let _ = start_prompt(app, session_id, vec![Part::Text { text: leftover.join("\n\n") }]); + if !leftover.is_empty() { + let _ = start_prompt(app, session_id, leftover); } }); - Ok(()) + Ok(Submitted::Started) +} + +/// Send queued messages now (after a stop or a server restart). +pub fn flush_queue(app: Arc, session_id: &str) -> Result { + if app.is_busy(session_id) { + return Ok(false); + } + let parts = take_queue(&app, session_id); + if parts.is_empty() { + return Ok(false); + } + start_prompt(app, session_id.to_string(), parts)?; + Ok(true) } pub fn abort(app: &App, session_id: &str) { @@ -77,17 +116,72 @@ pub fn abort(app: &App, session_id: &str) { if let Some(c) = &r.cancel { c.cancel(); } - r.queue.clear(); }); } -/// Messages since the last compaction summary. +/// Messages since the last compaction summary. Tool calls left without a +/// result (run interrupted, e.g. by a server restart) get a synthetic result: +/// providers reject histories with unanswered tool calls. fn active_history(all: Vec) -> Vec { let start = all .iter() .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) .unwrap_or(0); - all.into_iter().skip(start).collect() + let msgs: Vec = all.into_iter().skip(start).collect(); + let mut out: Vec = Vec::with_capacity(msgs.len()); + for (i, m) in msgs.iter().enumerate() { + out.push(m.clone()); + if m.role != Role::Assistant { + continue; + } + let answered: std::collections::HashSet<&str> = msgs + .get(i + 1) + .filter(|n| n.role == Role::User) + .map(|n| { + n.parts + .iter() + .filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.as_str()) } else { None }) + .collect() + }) + .unwrap_or_default(); + let missing: Vec = m + .parts + .iter() + .filter_map(|p| match p { + Part::ToolCall { id, name, .. } if !answered.contains(id.as_str()) => { + Some(tool_result(id, name, "Not executed: the run was interrupted (e.g. server restart).".into(), true, None)) + } + _ => None, + }) + .collect(); + if missing.is_empty() { + continue; + } + // Results must directly follow the call: merge into the next user message or insert one. + match msgs.get(i + 1) { + Some(n) if n.role == Role::User => { + // The next message is pushed in the next iteration; prepend there. + let mut next = n.clone(); + let mut parts = missing; + parts.extend(next.parts); + next.parts = parts; + // Replace by pushing now and skipping the original. + out.push(next); + } + _ => out.push(Message::new(Role::User, missing)), + } + } + // Remove duplicates created by the merge above (same id pushed twice: keep the merged one). + let mut dedup: Vec = Vec::with_capacity(out.len()); + for m in out { + if let Some(last) = dedup.last() { + if last.id == m.id { + continue; + } + } + dedup.push(m); + } + dedup } fn normalize_tool_name(s: &str) -> String { @@ -95,8 +189,17 @@ fn normalize_tool_name(s: &str) -> String { } pub fn available_tools(app: &App, agent: &Agent) -> Vec> { + let cfg = app.config(); let mut all = tools::builtin_tools(); - all.extend(app.mcp.tools()); + if !cfg.images.enabled { + all.retain(|t| t.name() != "generate_image"); + } + let (direct, deferred) = app.mcp.tools(&cfg); + all.extend(direct); + if !deferred.is_empty() { + all.push(Arc::new(crate::mcp::McpSearch)); + all.push(Arc::new(crate::mcp::McpCall)); + } all.into_iter() .filter(|t| !agent.read_only || t.read_only()) // Craft tools only where they are useful. @@ -107,7 +210,7 @@ pub fn available_tools(app: &App, agent: &Agent) -> Vec> { let n = normalize_tool_name(t.name()); list.iter().any(|a| { let a = normalize_tool_name(a); - a == n || (t.name().starts_with("mcp__") && a == "mcp") + a == n || (t.name().starts_with("mcp") && a == "mcp") }) } }) @@ -119,6 +222,17 @@ struct Step { stop_reason: String, } +/// Boxed with an explicit `Send` bound: subagents recurse into the agent loop, +/// and the declared type breaks the cycle for the compiler. +fn run_session_boxed( + app: Arc, + session_id: String, + cancel: CancellationToken, + depth: usize, +) -> futures::future::BoxFuture<'static, Result> { + Box::pin(async move { run_session(app, &session_id, cancel, depth).await }) +} + /// Run the loop for a session until the model stops. Returns the final text. pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationToken, depth: usize) -> Result { let mut final_text = String::new(); @@ -128,7 +242,9 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo } let session = app.session(session_id)?; let cwd = PathBuf::from(&session.cwd); - let agent = prompts::find_agent(&app, &cwd, &session.agent); + let roots = app.user_roots(session.owner_id.as_deref()); + let root = app.root_for(session.owner_id.as_deref(), &cwd); + let agent = prompts::find_agent(&app, &root, &cwd, &session.agent); let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone()); let (provider, model) = app.registry().resolve(&model_spec)?; @@ -144,7 +260,7 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo let tools = available_tools(&app, &agent); let history = active_history(app.store.messages(session_id)?); let req = ChatRequest { - system: prompts::system_prompt(&app, &agent, &cwd, &model_spec), + system: prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode), messages: history, tools: tools.iter().map(|t| t.def()).collect(), max_tokens: model.max_output.unwrap_or(16_000), @@ -203,6 +319,7 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo cwd: cwd.clone(), cancel: cancel.clone(), agent: agent.name.clone(), + roots: roots.clone(), }; let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only())); @@ -217,11 +334,8 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await); } } - // Inject prompts the user sent while we were working. - let queued = app.with_runtime(session_id, |r| std::mem::take(&mut r.queue)); - for q in queued { - results.push(Part::Text { text: q }); - } + // Deliver messages the user queued while we were working (with attachments). + results.extend(take_queue(&app, session_id)); save_user_parts(&app, session_id, results)?; } Ok(final_text) @@ -265,12 +379,6 @@ async fn execute_call( return tool_result(call_id, name, "Maximum subagent depth reached.".into(), true, None); } - // Permission check. Approvals are tracked on the top-level session. - let root = app.root_session_id(&session.id); - let cfg = app.config(); - let decision = app.with_runtime(&root, |r| { - Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) - }); let ctx = ToolCtx { app: app.clone(), session_id: base.session_id.clone(), @@ -278,10 +386,33 @@ async fn execute_call( cwd: base.cwd.clone(), cancel: base.cancel.clone(), agent: base.agent.clone(), + roots: base.roots.clone(), }; + // mcp_call is judged (and approved) as the MCP tool it calls. + let call_name = name; + let (tool, input, name): (Arc, Value, String) = if name == "mcp_call" { + match crate::mcp::resolve_call_target(&ctx, &input) { + Ok(target) => { + let args = input.get("arguments").cloned().unwrap_or(serde_json::json!({})); + let n = target.full_name.clone(); + (target, args, n) + } + Err(e) => return tool_result(call_id, call_name, format!("Error: {e:#}"), true, None), + } + } else { + (tool, input, name.to_string()) + }; + let name = name.as_str(); + + // Permission check. Approvals are tracked on the top-level session. + let root = app.root_session_id(&session.id); + let cfg = app.config(); + let decision = app.with_runtime(&root, |r| { + Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) + }); match decision { Decision::Deny => { - return tool_result(call_id, name, "Denied by the permission rules in the configuration.".into(), true, None); + return tool_result(call_id, call_name, "Denied by the permission rules in the configuration.".into(), true, None); } Decision::Ask => { let diff = tool.preview(&input, &ctx).await; @@ -294,7 +425,7 @@ async fn execute_call( if let Some(m) = message.filter(|m| !m.trim().is_empty()) { text.push_str(&format!(" User feedback: {m}")); } - return tool_result(call_id, name, text, true, None); + return tool_result(call_id, call_name, text, true, None); } PermissionReply::Always => app.with_runtime(&root, |r| { if name == "bash" { @@ -321,40 +452,76 @@ async fn execute_call( match res { Ok(out) => Part::ToolResult { call_id: call_id.into(), - name: name.into(), + name: call_name.into(), content: out.content, images: out.images, is_error: out.is_error, meta: out.meta, }, - Err(e) => tool_result(call_id, name, format!("Error: {e:#}"), true, None), + Err(e) => tool_result(call_id, call_name, format!("Error: {e:#}"), true, None), } } async fn run_task(app: &Arc, ctx: &ToolCtx, input: Value, depth: usize) -> Result { let prompt = tools::arg_str(&input, "prompt")?.to_string(); let description = input["description"].as_str().unwrap_or("subagent").to_string(); - let agent = input["subagent_type"].as_str().unwrap_or("general").to_string(); - let (child, text) = spawn_subagent(app.clone(), ctx, &agent, &description, prompt, depth + 1).await?; - Ok(tools::ToolOutput::text(text).with_meta(serde_json::json!({"session_id": child, "agent": agent}))) + let agent_name = input["subagent_type"].as_str().unwrap_or("general").to_string(); + let background = input["background"].as_bool().unwrap_or(false); + let (child, agent) = create_child(app, ctx, &agent_name, &description, prompt)?; + let meta = serde_json::json!({ + "session_id": child.id, "agent": agent.name, "model": child.model, "background": background + }); + + if background { + let app = app.clone(); + let parent_id = ctx.session_id.clone(); + let child_id = child.id.clone(); + let agent_name = agent.name.clone(); + let desc = description.clone(); + // Background tasks outlive the current turn; they are stopped via their own session. + let cancel = CancellationToken::new(); + tokio::spawn(async move { + let res = run_child(app.clone(), &child, &agent, cancel, depth + 1).await; + let body = match res { + Ok(text) => text, + Err(e) => format!("The background task failed: {e:#}"), + }; + let text = format!( + "\n{body}\n" + ); + // Injected into the running turn, or starts a new turn when the parent is idle. + if app.store.get_session(&parent_id).ok().flatten().is_some() { + if let Err(e) = submit(app.clone(), parent_id.clone(), vec![Part::Text { text }], "background") { + tracing::warn!("could not deliver background result: {e:#}"); + } + } + }); + return Ok(tools::ToolOutput::text(format!( + "Started background task '{description}' with subagent '{}' (model {}). Its result will arrive \ + automatically as a message when it finishes – continue with other work and do not poll.", + meta["agent"].as_str().unwrap_or(""), + meta["model"].as_str().unwrap_or("") + )) + .with_meta(meta)); + } + + let text = run_child(app.clone(), &child, &agent, ctx.cancel.child_token(), depth + 1).await?; + Ok(tools::ToolOutput::text(text).with_meta(meta)) } /// Entry point used by the task tool when called outside the runner. pub async fn run_subagent(app: Arc, ctx: &ToolCtx, agent: &str, description: &str, prompt: String) -> Result<(String, String)> { - spawn_subagent(app, ctx, agent, description, prompt, 1).await + let (child, agent) = create_child(&app, ctx, agent, description, prompt)?; + let text = run_child(app, &child, &agent, ctx.cancel.child_token(), 1).await?; + Ok((child.id, text)) } -async fn spawn_subagent( - app: Arc, - ctx: &ToolCtx, - agent_name: &str, - description: &str, - prompt: String, - depth: usize, -) -> Result<(String, String)> { +/// Create the subagent session with its first message. +fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &str, prompt: String) -> Result<(Session, Agent)> { let parent = app.session(&ctx.session_id)?; - let parent_agent = prompts::find_agent(&app, &ctx.cwd, &parent.agent); - let agents = prompts::agents(&app, &ctx.cwd); + let root = ctx.root(); + let parent_agent = prompts::find_agent(app, &root, &ctx.cwd, &parent.agent); + let agents = prompts::agents(app, &root, &ctx.cwd); let mut agent = agents .iter() .find(|a| a.name == agent_name && a.mode != "primary") @@ -367,6 +534,9 @@ async fn spawn_subagent( if parent_agent.read_only && !agent.read_only { agent = agents.into_iter().find(|a| a.name == "explore").unwrap_or(agent); } + if let Some(m) = &agent.model { + app.registry().resolve(m).map_err(|e| anyhow!("subagent '{}' has an unusable model: {e:#}", agent.name))?; + } let now = now_ms(); let child = Session { id: new_id(), @@ -376,6 +546,7 @@ async fn spawn_subagent( agent: agent.name.clone(), permission_mode: parent.permission_mode.clone(), parent_id: Some(parent.id.clone()), + owner_id: parent.owner_id.clone(), created_at: now, updated_at: now, todos: Value::Null, @@ -385,11 +556,34 @@ async fn spawn_subagent( let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); app.store.add_message(&child.id, &msg)?; app.emit(Event::MessageAdded { session_id: child.id.clone(), message: msg }); + Ok((child, agent)) +} + +/// Run a subagent session to completion, honouring the subagent limits. +async fn run_child(app: Arc, child: &Session, agent: &Agent, cancel: CancellationToken, depth: usize) -> Result { + // Registered like a normal run so the user can stop it from its own session. + app.with_runtime(&child.id, |r| r.cancel = Some(cancel.clone())); app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: true }); - let res = Box::pin(run_session(app.clone(), &child.id, ctx.cancel.child_token(), depth)).await; + // Only first-level subagents count against the limits: nested subagents would + // otherwise deadlock waiting for a slot their own parent holds. + let _slot = if depth <= 1 { + let (slot, waited) = app.subagent_slot(&agent.name, agent.max_parallel, &child.id, &cancel).await; + if waited { + app.emit(Event::Waiting { session_id: child.id.clone(), reason: None }); + } + slot + } else { + None + }; + let res = if cancel.is_cancelled() { + Err(anyhow!("aborted")) + } else { + run_session_boxed(app.clone(), child.id.clone(), cancel, depth).await + }; + app.with_runtime(&child.id, |r| r.cancel = None); app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: false }); let text = res?; - Ok((child.id, if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })) + Ok(if text.trim().is_empty() { "(subagent returned no text)".into() } else { text }) } fn is_retryable(e: &anyhow::Error) -> bool { @@ -415,7 +609,20 @@ async fn stream_step( let p = provider.clone(); let r = req.clone(); let c = cancel.clone(); - let handle = tokio::spawn(async move { p.stream(r, tx, c).await }); + // Wait for a free slot on the model/host; not counted as time to first token. + let registry = app.registry(); + if registry.is_saturated(model_spec) { + app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("model:{model_spec}")) }); + } + let slot = tokio::select! { + s = registry.acquire(model_spec) => s, + _ = cancel.cancelled() => bail!("aborted"), + }; + app.emit(Event::Waiting { session_id: session.id.clone(), reason: None }); + let handle = tokio::spawn(async move { + let _slot = slot; + p.stream(r, tx, c).await + }); let started = Instant::now(); let mut ttft: Option = None; @@ -516,9 +723,13 @@ async fn stream_step( } /// Collect a complete, tool-free answer (titles, summaries). -async fn complete_text(provider: Arc, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> { +async fn complete_text(app: &App, spec: &str, provider: Arc, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> { + let slot = app.registry().acquire(spec).await; let (tx, mut rx) = mpsc::channel(256); - let handle = tokio::spawn(async move { provider.stream(req, tx, cancel).await }); + let handle = tokio::spawn(async move { + let _slot = slot; + provider.stream(req, tx, cancel).await + }); let mut text = String::new(); let mut usage = Usage::default(); while let Some(ev) = rx.recv().await { @@ -579,7 +790,7 @@ fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { reasoning: false, model: model.clone(), }; - match complete_text(provider, req, CancellationToken::new()).await { + match complete_text(&app, &spec, provider, req, CancellationToken::new()).await { Ok((title, usage)) => { record_side_usage(&app, &session, &spec, &model, &usage, "title"); let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string(); @@ -625,7 +836,7 @@ pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) reasoning: false, model: model.clone(), }; - let (summary, usage) = complete_text(provider, req, cancel).await?; + let (summary, usage) = complete_text(&app, &spec, provider, req, cancel).await?; record_side_usage(&app, &session, &spec, &model, &usage, "compaction"); if summary.trim().is_empty() { bail!("compaction produced an empty summary"); @@ -638,3 +849,25 @@ pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) app.save_session(s)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn call(id: &str) -> Part { + Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None } + } + + #[test] + fn dangling_tool_calls_get_results() { + let a = Message::new(Role::Assistant, vec![call("1"), call("2")]); + let r = Message::new(Role::User, vec![tool_result("1", "bash", "ok".into(), false, None)]); + let a2 = Message::new(Role::Assistant, vec![call("3")]); + let h = active_history(vec![Message::new(Role::User, vec![Part::Text { text: "hi".into() }]), a, r, a2]); + // user, assistant(1,2), user(result 2 + result 1), assistant(3), user(result 3) + assert_eq!(h.len(), 5); + let ids = |m: &Message| m.parts.iter().filter_map(|p| if let Part::ToolResult { call_id, .. } = p { Some(call_id.clone()) } else { None }).collect::>(); + assert_eq!(ids(&h[2]), vec!["2", "1"]); + assert_eq!(ids(&h[4]), vec!["3"]); + } +} diff --git a/server/src/store.rs b/server/src/store.rs index dc1ab67..f78b5e0 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -50,7 +50,74 @@ impl Store { error INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts); - CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);", + CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id); + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + role TEXT NOT NULL DEFAULT 'user', + oidc_sub TEXT UNIQUE, + disabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS auth_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + user_agent TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS folder_access ( + folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (folder_id, user_id) + ); + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + user_id TEXT, + session_id TEXT, + prompt TEXT NOT NULL, + model TEXT NOT NULL, + path TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at); + CREATE TABLE IF NOT EXISTS queue ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + data TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at); + CREATE TABLE IF NOT EXISTS session_shares ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (session_id, user_id) + );", + )?; + // Columns added after the first release. + for (table, column, ddl) in [ + ("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"), + ("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"), + ] { + let exists: bool = conn + .prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))? + .exists([column])?; + if !exists { + conn.execute(ddl, [])?; + } + } + conn.execute_batch( + "CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(owner_id); + CREATE INDEX IF NOT EXISTS usage_user ON usage(user_id);", )?; Ok(Self { conn: Mutex::new(conn) }) } @@ -58,9 +125,10 @@ impl Store { pub fn save_session(&self, s: &Session) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", - params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at], + "INSERT INTO sessions (id, data, parent_id, updated_at, owner_id) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at, + owner_id = excluded.owner_id", + params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at, s.owner_id], )?; Ok(()) } @@ -74,12 +142,15 @@ impl Store { } /// Top-level sessions (subagent sessions are reachable through their parent). - pub fn list_sessions(&self) -> Result> { + /// `user = None` lists all sessions (admins); otherwise own and shared ones. + pub fn list_sessions(&self, user: Option<&str>) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500", + "SELECT data FROM sessions WHERE parent_id IS NULL AND (?1 IS NULL OR owner_id = ?1 + OR id IN (SELECT session_id FROM session_shares WHERE user_id = ?1)) + ORDER BY updated_at DESC LIMIT 500", )?; - let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let rows = stmt.query_map([user], |r| r.get::<_, String>(0))?; let mut out = Vec::new(); for row in rows { out.push(serde_json::from_str(&row?)?); @@ -197,8 +268,9 @@ impl Store { conn.execute( "INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, - ttft_ms, duration_ms, cost, error) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", + ttft_ms, duration_ms, cost, error, user_id) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14, + (SELECT owner_id FROM sessions WHERE id = ?3))", params![ u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent, u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64, @@ -232,21 +304,22 @@ impl Store { /// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets /// into the viewer's local time. - pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result { + pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result { let conn = self.conn.lock().unwrap(); let shift = tz_offset_min * 60_000; let total = conn.query_row( - &format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"), - params![from, to], + &format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?3 IS NULL OR user_id = ?3)"), + params![from, to, user], |r| agg_row(r, 0), )?; let grouped = |key_sql: &str, key_name: &str| -> Result> { let mut stmt = conn.prepare(&format!( - "SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1" + "SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 + AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1" ))?; let rows = stmt - .query_map(params![from, to, shift], |r| { + .query_map(params![from, to, shift, user], |r| { let mut v = agg_row(r, 1)?; v[key_name] = match r.get_ref(0)? { rusqlite::types::ValueRef::Integer(i) => json!(i), @@ -264,6 +337,10 @@ impl Store { let by_model = grouped("provider || '/' || model", "model")?; let by_provider = grouped("provider", "provider")?; let by_agent = grouped("agent", "agent")?; + let by_user = grouped( + "COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')", + "user", + )?; // Heatmap: weekday (0 = Sunday) x hour of day. let heatmap = grouped( "CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \ @@ -274,9 +351,10 @@ impl Store { let mut stmt = conn.prepare(&format!( "SELECT {day_sql}, provider || '/' || model, SUM(cost), SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens) - FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1" + FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?4 IS NULL OR user_id = ?4) + GROUP BY 1, 2 ORDER BY 1" ))?; - stmt.query_map(params![from, to, shift], |r| { + stmt.query_map(params![from, to, shift, user], |r| { Ok(json!({ "day": r.get::<_, String>(0)?, "model": r.get::<_, String>(1)?, @@ -292,10 +370,10 @@ impl Store { let mut stmt = conn.prepare(&format!( "SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG} FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id - WHERE u.ts >= ?1 AND u.ts < ?2 + WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3) GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15" ))?; - stmt.query_map(params![from, to], |r| { + stmt.query_map(params![from, to, user], |r| { let mut v = agg_row(r, 2)?; v["session_id"] = json!(r.get::<_, String>(0)?); v["title"] = json!(r.get::<_, String>(1)?); @@ -310,8 +388,385 @@ impl Store { "by_model": by_model, "by_provider": by_provider, "by_agent": by_agent, + "by_user": by_user, "heatmap": heatmap, "top_sessions": top_sessions, })) } } + +// --- users & auth ------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct User { + pub id: String, + pub username: String, + pub display_name: String, + pub role: String, + pub has_password: bool, + pub oidc: bool, + pub disabled: bool, + pub created_at: i64, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +const USER_COLS: &str = + "id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at"; + +fn user_row(r: &rusqlite::Row) -> rusqlite::Result { + Ok(User { + id: r.get(0)?, + username: r.get(1)?, + display_name: r.get(2)?, + role: r.get(3)?, + has_password: r.get(4)?, + oidc: r.get(5)?, + disabled: r.get::<_, i64>(6)? != 0, + created_at: r.get(7)?, + }) +} + +impl Store { + pub fn user_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?) + } + + pub fn create_user(&self, username: &str, display_name: &str, role: &str, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result { + let id = crate::types::new_id(); + { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO users (id, username, display_name, password_hash, role, oidc_sub, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, username, display_name, password_hash, role, oidc_sub, now_ms()], + )?; + } + self.user(&id)?.ok_or_else(|| anyhow::anyhow!("user vanished")) + } + + pub fn user(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row).optional()?) + } + + pub fn user_by_name(&self, username: &str) -> Result)>> { + let conn = self.conn.lock().unwrap(); + Ok(conn + .query_row( + &format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"), + [username], + |r| Ok((user_row(r)?, r.get::<_, Option>(8)?)), + ) + .optional()?) + } + + pub fn user_by_oidc(&self, sub: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE oidc_sub = ?1"), [sub], user_row).optional()?) + } + + pub fn users(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY username"))?; + let rows = stmt.query_map([], user_row)?.collect::>>()?; + Ok(rows) + } + + /// Update selected fields; None leaves a field unchanged. + pub fn update_user(&self, id: &str, display_name: Option<&str>, role: Option<&str>, disabled: Option, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE users SET display_name = COALESCE(?2, display_name), role = COALESCE(?3, role), + disabled = COALESCE(?4, disabled), password_hash = COALESCE(?5, password_hash), + oidc_sub = COALESCE(?6, oidc_sub) WHERE id = ?1", + params![id, display_name, role, disabled.map(|d| d as i64), password_hash, oidc_sub], + )?; + if disabled == Some(true) { + conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [id])?; + } + Ok(()) + } + + pub fn delete_user(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM users WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn admin_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0", [], |r| r.get(0))?) + } + + pub fn add_token(&self, token_hash: &str, user_id: &str, expires_at: i64, user_agent: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?1", [now_ms()])?; + conn.execute( + "INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?5)", + params![token_hash, user_id, now_ms(), expires_at, user_agent], + )?; + Ok(()) + } + + /// The (enabled) user behind a valid login token. + pub fn user_by_token(&self, token_hash: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn + .query_row( + &format!( + "SELECT {} FROM auth_tokens t JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ?1 AND t.expires_at > ?2 AND u.disabled = 0", + USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::>().join(", ") + ), + params![token_hash, now_ms()], + user_row, + ) + .optional()?) + } + + pub fn delete_token(&self, token_hash: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM auth_tokens WHERE token_hash = ?1", [token_hash])?; + Ok(()) + } + + pub fn delete_user_tokens(&self, user_id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [user_id])?; + Ok(()) + } + + pub fn shares(&self, session_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT user_id FROM session_shares WHERE session_id = ?1")?; + let rows = stmt.query_map([session_id], |r| r.get(0))?.collect::>>()?; + Ok(rows) + } + + pub fn set_shares(&self, session_id: &str, users: &[String]) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM session_shares WHERE session_id = ?1", [session_id])?; + for u in users { + conn.execute("INSERT OR IGNORE INTO session_shares (session_id, user_id) VALUES (?1, ?2)", params![session_id, u])?; + } + Ok(()) + } + + /// Assign sessions without owner (created before user management) to a user. + pub fn adopt_orphans(&self, user_id: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let ids: Vec<(String, String)> = { + let mut stmt = conn.prepare("SELECT id, data FROM sessions WHERE owner_id IS NULL")?; + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::>>()? + }; + for (id, data) in &ids { + let mut v: Value = serde_json::from_str(data)?; + v["owner_id"] = json!(user_id); + conn.execute("UPDATE sessions SET owner_id = ?2, data = ?3 WHERE id = ?1", params![id, user_id, v.to_string()])?; + } + conn.execute( + "UPDATE usage SET user_id = ?1 WHERE user_id IS NULL", + [user_id], + )?; + Ok(ids.len()) + } +} + +// --- shared folders ----------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct Folder { + pub id: String, + /// Absolute path inside the workspace. + pub path: String, + pub users: Vec, + pub created_at: i64, +} + +impl Store { + pub fn folders(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT f.id, f.path, f.created_at, COALESCE(group_concat(a.user_id), '') + FROM folders f LEFT JOIN folder_access a ON a.folder_id = f.id GROUP BY f.id ORDER BY f.path", + )?; + let rows = stmt + .query_map([], |r| { + let users: String = r.get(3)?; + Ok(Folder { + id: r.get(0)?, + path: r.get(1)?, + created_at: r.get(2)?, + users: users.split(',').filter(|u| !u.is_empty()).map(String::from).collect(), + }) + })? + .collect::>>()?; + Ok(rows) + } + + pub fn add_folder(&self, path: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let id = crate::types::new_id(); + conn.execute("INSERT INTO folders (id, path, created_at) VALUES (?1, ?2, ?3)", params![id, path, now_ms()])?; + Ok(id) + } + + pub fn delete_folder(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM folders WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn set_folder_access(&self, id: &str, users: &[String]) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM folder_access WHERE folder_id = ?1", [id])?; + for u in users { + conn.execute("INSERT OR IGNORE INTO folder_access (folder_id, user_id) VALUES (?1, ?2)", params![id, u])?; + } + Ok(()) + } + + /// Paths of folders shared with a user. + pub fn folders_for_user(&self, user_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT f.path FROM folders f JOIN folder_access a ON a.folder_id = f.id WHERE a.user_id = ?1 ORDER BY f.path", + )?; + let rows = stmt.query_map([user_id], |r| r.get(0))?.collect::>>()?; + Ok(rows) + } +} + +// --- generated images --------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct ImageRecord { + pub id: String, + pub user_id: Option, + pub session_id: Option, + pub prompt: String, + pub model: String, + pub path: String, + pub width: i64, + pub height: i64, + pub created_at: i64, +} + +fn image_row(r: &rusqlite::Row) -> rusqlite::Result { + Ok(ImageRecord { + id: r.get(0)?, + user_id: r.get(1)?, + session_id: r.get(2)?, + prompt: r.get(3)?, + model: r.get(4)?, + path: r.get(5)?, + width: r.get(6)?, + height: r.get(7)?, + created_at: r.get(8)?, + }) +} + +const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at"; + +impl Store { + pub fn add_image(&self, img: &ImageRecord) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + &format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"), + params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at], + )?; + Ok(()) + } + + /// Latest images; `user = None` lists everybody's (admins). + pub fn images(&self, user: Option<&str>, limit: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!( + "SELECT {IMAGE_COLS} FROM images WHERE ?1 IS NULL OR user_id = ?1 ORDER BY created_at DESC LIMIT ?2" + ))?; + let rows = stmt.query_map(params![user, limit], image_row)?.collect::>>()?; + Ok(rows) + } + + pub fn image(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row(&format!("SELECT {IMAGE_COLS} FROM images WHERE id = ?1"), [id], image_row).optional()?) + } + + pub fn delete_image(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM images WHERE id = ?1", [id])?; + Ok(()) + } +} + +impl Store { + /// Cost per provider since `from` (ms). + pub fn cost_by_provider(&self, from: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?; + let rows = stmt.query_map([from], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))?; + let mut out = std::collections::BTreeMap::new(); + for r in rows { + let (p, c) = r?; + out.insert(p, c); + } + Ok(out) + } +} + +// --- message queue ------------------------------------------------------------- + +/// A message waiting to be delivered to a busy session. +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +pub struct QueueItem { + pub id: String, + pub parts: Vec, + /// "user" or "background" (results of background subagents). + pub source: String, + pub created_at: i64, +} + +impl Store { + pub fn queue_add(&self, session_id: &str, item: &QueueItem) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO queue (id, session_id, data, created_at) VALUES (?1, ?2, ?3, ?4)", + params![item.id, session_id, serde_json::to_string(item)?, item.created_at], + )?; + Ok(()) + } + + pub fn queue_list(&self, session_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT data FROM queue WHERE session_id = ?1 ORDER BY created_at, rowid")?; + let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for r in rows { + out.push(serde_json::from_str(&r?)?); + } + Ok(out) + } + + /// Remove one item; false if it was already delivered. + pub fn queue_remove(&self, session_id: &str, id: &str) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.execute("DELETE FROM queue WHERE session_id = ?1 AND id = ?2", params![session_id, id])? > 0) + } + + /// Take all items for delivery. + pub fn queue_take(&self, session_id: &str) -> Result> { + let items = self.queue_list(session_id)?; + let conn = self.conn.lock().unwrap(); + for it in &items { + conn.execute("DELETE FROM queue WHERE id = ?1", [&it.id])?; + } + Ok(items) + } +} diff --git a/server/src/tools/agent.rs b/server/src/tools/agent.rs index 9ee4304..48f5873 100644 --- a/server/src/tools/agent.rs +++ b/server/src/tools/agent.rs @@ -143,7 +143,8 @@ impl Tool for Task { "Launch a subagent to handle an independent, multi-step task in its own context (e.g. a \ broad codebase search, a focused review). The subagent only knows what you put in the \ prompt – write a complete, self-contained brief. Its final message is returned to you. \ - Several task calls in one response run in parallel." + Several task calls in one response run in parallel (subject to the configured limits). \ + With background=true you continue working and receive the result later." .into() } fn schema(&self) -> Value { @@ -152,7 +153,8 @@ impl Tool for Task { "properties": { "description": {"type": "string", "description": "Short (3-5 words) task description"}, "prompt": {"type": "string", "description": "The full brief for the subagent"}, - "subagent_type": {"type": "string", "description": "Subagent to use (default: general)"} + "subagent_type": {"type": "string", "description": "Subagent to use (default: general)"}, + "background": {"type": "boolean", "description": "Run in the background: returns immediately, the result arrives later as a message. Use for work you don't need right away (e.g. writing docs, summaries, translations)."} }, "required": ["description", "prompt"] }) diff --git a/server/src/tools/mod.rs b/server/src/tools/mod.rs index 27a34ae..44e0098 100644 --- a/server/src/tools/mod.rs +++ b/server/src/tools/mod.rs @@ -38,6 +38,7 @@ impl ToolOutput { } } +#[derive(Clone)] pub struct ToolCtx { pub app: Arc, pub session_id: String, @@ -46,6 +47,8 @@ pub struct ToolCtx { pub cancel: CancellationToken, /// Agent mode running this tool (subagents inherit restrictions). pub agent: String, + /// Directories this session may access (the owner's roots). + pub roots: Vec, } impl ToolCtx { @@ -55,11 +58,24 @@ impl ToolCtx { let p = p.trim(); let raw = if Path::new(p).is_absolute() { PathBuf::from(p) } else { self.cwd.join(p) }; let norm = normalize(&raw); - let ws = normalize(&self.app.workspace()); - if !norm.starts_with(&ws) && !norm.starts_with("/tmp") { - bail!("path {} is outside the workspace {}", norm.display(), ws.display()); + if norm.starts_with("/tmp") || self.roots.iter().any(|r| norm.starts_with(normalize(r))) { + // Symlinks must not lead out of the allowed directories either. + if let Ok(real) = std::fs::canonicalize(&norm) { + if !real.starts_with("/tmp") && !self.roots.iter().any(|r| real.starts_with(r)) { + bail!("path {} resolves outside your directories", norm.display()); + } + } + return Ok(norm); } - Ok(norm) + let allowed: Vec = self.roots.iter().map(|r| r.display().to_string()).collect(); + bail!("path {} is outside your directories ({})", norm.display(), allowed.join(", ")) + } + + /// The root directory containing the working directory. + pub fn root(&self) -> PathBuf { + let mut roots = self.roots.clone(); + roots.sort_by_key(|r| std::cmp::Reverse(r.as_os_str().len())); + roots.iter().find(|r| self.cwd.starts_with(r)).cloned().unwrap_or_else(|| self.cwd.clone()) } pub fn progress(&self, output: impl Into) { @@ -131,6 +147,7 @@ pub fn builtin_tools() -> Vec> { Arc::new(craft::BrowserSnapshot), Arc::new(craft::ImageCompare), Arc::new(crate::prompts::SkillTool), + Arc::new(crate::images::GenerateImage), ] } diff --git a/server/src/types.rs b/server/src/types.rs index 13d7b56..03746d9 100644 --- a/server/src/types.rs +++ b/server/src/types.rs @@ -115,6 +115,9 @@ pub struct Session { pub permission_mode: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_id: Option, + /// User who owns the session (subagent sessions inherit it). + #[serde(default)] + pub owner_id: Option, pub created_at: i64, pub updated_at: i64, #[serde(default)] @@ -160,7 +163,36 @@ pub enum Event { QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec }, QuestionResolved { id: String, session_id: String }, Error { session_id: String, message: String }, + /// The session waits for a free model or subagent slot (reason None = no longer waiting). + /// reason: "model:" or "subagent:". + Waiting { session_id: String, reason: Option }, McpStatus { servers: Value }, + /// Messages waiting for a busy session (full list). + QueueUpdated { session_id: String, items: Value }, +} + +impl Event { + pub fn session_id(&self) -> Option<&str> { + use Event::*; + match self { + SessionUpdated { session } => Some(&session.id), + SessionDeleted { session_id } + | SessionStatus { session_id, .. } + | MessageAdded { session_id, .. } + | TextDelta { session_id, .. } + | ReasoningDelta { session_id, .. } + | ToolStarted { session_id, .. } + | ToolProgress { session_id, .. } + | PermissionRequest { session_id, .. } + | PermissionResolved { session_id, .. } + | QuestionRequest { session_id, .. } + | QuestionResolved { session_id, .. } + | Error { session_id, .. } + | Waiting { session_id, .. } + | QueueUpdated { session_id, .. } => Some(session_id), + McpStatus { .. } => None, + } + } } pub fn new_id() -> String { diff --git a/web/package-lock.json b/web/package-lock.json index ec70a5e..7e8db87 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,14 @@ "name": "codecraft-web", "version": "0.1.0", "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.13.1", + "@codemirror/language-data": "^6.5.2", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.15", + "@lezer/highlight": "^1.2.5", + "codemirror": "^6.0.2", "react": "^19.3.1", "react-dom": "^19.3.1", "react-markdown": "^10.1.0", @@ -1582,6 +1590,414 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.4", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz", + "integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.12", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", + "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz", + "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.3.tgz", + "integrity": "sha512-U9EnyzWuAx1TX3MZSKDXydKH734n1Aogk/ijKdW+jdIwmbf9zSPQYOIKggC/b2PTRjQb7ddvMktmxnfTLuv/SQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", + "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz", + "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/streamparser": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.5.tgz", + "integrity": "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==", + "license": "MIT", + "dependencies": { + "@codemirror/streamparser": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/streamparser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz", + "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==", + "license": "MIT" + }, + "node_modules/@codemirror/view": { + "version": "6.43.15", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz", + "integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -1653,6 +2069,189 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz", + "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz", + "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz", + "integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.5.tgz", + "integrity": "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz", + "integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz", + "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.1.tgz", + "integrity": "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz", + "integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz", + "integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.156.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", @@ -3250,6 +3849,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3301,6 +3915,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7139,6 +7759,12 @@ "node": ">=10" } }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, "node_modules/style-to-object": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-2.0.2.tgz", @@ -7723,6 +8349,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 1b6a000..b8198d9 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.13.1", + "@codemirror/language-data": "^6.5.2", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.15", + "@lezer/highlight": "^1.2.5", + "codemirror": "^6.0.2", "react": "^19.3.1", "react-dom": "^19.3.1", "react-markdown": "^10.1.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index d3f36f8..ca5f608 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,39 +1,48 @@ -import { useEffect, useState } from "react"; -import { ApiError, connectEvents, get, getToken } from "./api"; +import { lazy, Suspense, useEffect, useState } from "react"; +import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { useT } from "./i18n"; import { navigate, useRoute } from "./router"; import { applyEvent, loadSessions, resync, setState } from "./store"; +import type { Info } from "./types"; import { Sidebar } from "./components/Sidebar"; import { Login } from "./components/Login"; import { ChatView } from "./components/ChatView"; import { NewSession } from "./components/NewSession"; import { Settings } from "./components/Settings"; import { Stats } from "./components/Stats"; +import { Images } from "./components/Images"; +import { Spinner, useTitleActivity } from "./components/Activity"; -type AuthState = "checking" | "login" | "ok"; +// The file manager (editor, git) is loaded on demand to keep the chat small. +const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); export function App() { const t = useT(); const route = useRoute(); - const [auth, setAuth] = useState("checking"); + const [info, setInfo] = useState(); + const [error, setError] = useState(); const [menuOpen, setMenuOpen] = useState(false); + useTitleActivity(); + + const refresh = async () => { + try { + const i = await get("/info"); + setState(() => ({ me: i.user })); + if (i.user) await loadSessions(); + setInfo(i); + } catch (e: any) { + setError(e.message); + } + }; - // Check whether a token is needed and whether ours works. useEffect(() => { - (async () => { - try { - const info = await get<{ auth_required: boolean }>("/info"); - if (info.auth_required && !getToken()) return setAuth("login"); - await loadSessions(); - setAuth("ok"); - } catch (e) { - setAuth(e instanceof ApiError && e.status === 401 ? "login" : "ok"); - } - })(); + setUnauthorizedHandler(() => setInfo((i) => (i ? { ...i, user: null } : i))); + void refresh(); }, []); + const signedIn = !!info?.user; useEffect(() => { - if (auth !== "ok") return; + if (!signedIn) return; return connectEvents({ onEvent: applyEvent, onOpen: () => { @@ -42,19 +51,27 @@ export function App() { }, onClose: () => setState(() => ({ connected: false })), }); - }, [auth]); + }, [signedIn]); // Close the mobile menu on navigation. useEffect(() => setMenuOpen(false), [route.join("/")]); - if (auth === "checking") return
{t("common.loading")}
; - if (auth === "login") return setAuth("ok")} />; + if (error) return
{error}
; + if (!info) return
{t("common.loading")}
; + if (!info.user) return ; const [section, arg] = route; let content; if (section === "s" && arg) content = setMenuOpen(true)} />; else if (section === "settings") content = setMenuOpen(true)} />; else if (section === "stats") content = setMenuOpen(true)} />; + else if (section === "images") content = setMenuOpen(true)} />; + else if (section === "files") + content = ( + }> + setMenuOpen(true)} /> + + ); else content = setMenuOpen(true)} onCreated={(id) => navigate(`/s/${id}`)} />; return ( diff --git a/web/src/api.ts b/web/src/api.ts index b48caa5..4a886a5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,14 +1,6 @@ import type { ServerEvent } from "./types"; -const TOKEN_KEY = "codecraft.token"; - -export function getToken(): string { - return localStorage.getItem(TOKEN_KEY) ?? ""; -} - -export function setToken(token: string) { - localStorage.setItem(TOKEN_KEY, token); -} +// Authentication uses an HttpOnly session cookie set by /api/auth/login. export class ApiError extends Error { constructor( @@ -21,20 +13,32 @@ export class ApiError extends Error { export async function api(path: string, init: RequestInit & { json?: unknown } = {}): Promise { const headers: Record = { ...(init.headers as Record) }; - const token = getToken(); - if (token) headers.Authorization = `Bearer ${token}`; let body = init.body; if (init.json !== undefined) { headers["Content-Type"] = "application/json"; body = JSON.stringify(init.json); } - const res = await fetch(`/api${path}`, { ...init, headers, body }); + const res = await fetch(`/api${path}`, { ...init, headers, body, credentials: "same-origin" }); + if (res.status === 401 && !path.startsWith("/auth/") && path !== "/info") onUnauthorized?.(); const text = await res.text(); - const data = text ? JSON.parse(text) : null; + let data: any = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + // Proxies answer with HTML error pages (e.g. 413 from nginx). + const hint = res.status === 413 ? "request too large – the reverse proxy rejected the upload (client_max_body_size)" : res.statusText || "unexpected response"; + throw new ApiError(res.status, `HTTP ${res.status}: ${hint}`); + } if (!res.ok) throw new ApiError(res.status, data?.error ?? res.statusText); return data as T; } +/** Called when the session expired (set by the app to show the login). */ +let onUnauthorized: (() => void) | undefined; +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn; +} + export const get = (path: string) => api(path); export const post = (path: string, json?: unknown) => api(path, { method: "POST", json: json ?? {} }); export const patch = (path: string, json: unknown) => api(path, { method: "PATCH", json }); @@ -55,8 +59,7 @@ export function connectEvents(handlers: { const open = () => { const proto = location.protocol === "https:" ? "wss" : "ws"; - const token = encodeURIComponent(getToken()); - ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${token}`); + ws = new WebSocket(`${proto}://${location.host}/api/ws`); ws.onopen = () => { retry = 0; handlers.onOpen(); diff --git a/web/src/components/Activity.tsx b/web/src/components/Activity.tsx new file mode 100644 index 0000000..0c84690 --- /dev/null +++ b/web/src/components/Activity.tsx @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { useT } from "../i18n"; +import { useStore, type Draft } from "../store"; +import type { Part } from "../types"; + +type ToolResult = Extract; + +/** Animated three-dot pulse. */ +export function Dots() { + return ( + + ); +} + +export function Spinner({ small }: { small?: boolean }) { + return