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
+
+
+
-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)
+
+
+
+
+
+
+
+
+ How it works ·
+ Modes ·
+ Models ·
+ Users ·
+ Statistics ·
+ Get started ·
+ Configuration ·
+ Themes ·
+ Development ·
+ Status
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+ 
+ Mobile: answer questions and approve changes from your phone.
+ |
+
+ 
+ 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
+
+
+
+
+
+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