Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
434aa84d40 | ||
|
|
0a368e493d | ||
|
|
011756ca3d | ||
|
|
b7c8741a7d | ||
|
|
bf99292f43 | ||
|
|
36de035dd8 | ||
|
|
b59c5b8890 | ||
|
|
eef17eb591 | ||
|
|
6b1d7f91b8 | ||
|
|
0f4bfa0ab0 |
No files matched your search
@@ -3,6 +3,37 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00007] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Telegram bot as a chat interface:** every user can connect their own bot (*Settings →
|
||||||
|
Account → Telegram*: token from @BotFather, then `/start <code>` pairs it with their Telegram
|
||||||
|
account – other chats are ignored). Messages, photos and files go to the current session; the
|
||||||
|
agent's steps show live, the answer comes formatted, images from tools come as photos, and
|
||||||
|
approvals and questions have buttons. Commands like the terminal client: `/sessions`,
|
||||||
|
`/session`, `/new`, `/clear`, `/mode`, `/model`, `/permissions`, `/effort`, `/stop`, `/status`,
|
||||||
|
`/costs`, `/memory`, `/jobs`, `/compact`, `/help`. The server polls Telegram – no public
|
||||||
|
webhook needed.
|
||||||
|
|
||||||
|
## [v202611-00006] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The image contains the Docker CLI (static client). With the host's `/var/run/docker.sock`
|
||||||
|
mounted (and its group), admins' agents and terminals can use the host's Docker;
|
||||||
|
`scripts/move-server.sh --docker-sock` sets this up on a new host.
|
||||||
|
- `scripts/move-server.sh --tools-dir DIR`: agent home, users' homes and `/tmp` on a separate disk.
|
||||||
|
|
||||||
|
## [v202611-00005] – 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Commits by agents, terminals and the git panel always use the name and e-mail from
|
||||||
|
*Settings → Account → Git* – also in repositories that have their own `user.name`/`user.email`
|
||||||
|
(agents had set those as a workaround while no identity was configured). Agents are told not
|
||||||
|
to change the git identity.
|
||||||
|
|
||||||
## [v202611-00004] – 2026-11-09
|
## [v202611-00004] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
@@ -3040,6 +3040,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
|
|||||||
+10
@@ -67,6 +67,14 @@ RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
|||||||
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
|
&& mkdir -p /out && cp target/x86_64-unknown-linux-musl/release/codecraft /out/codecraft-linux-x86_64 \
|
||||||
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
|
&& cp target/aarch64-unknown-linux-musl/release/codecraft /out/codecraft-linux-aarch64
|
||||||
|
|
||||||
|
# --- Docker CLI (static client; the daemon is the host's via the mounted socket) --
|
||||||
|
FROM toolchain AS dockercli
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG DOCKER_VERSION=29.9.3
|
||||||
|
RUN case "$TARGETARCH" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported $TARGETARCH" >&2; exit 1 ;; esac \
|
||||||
|
&& curl -fsSL "https://download.docker.com/linux/static/stable/$arch/docker-$DOCKER_VERSION.tgz" | tar -xz -C /tmp docker/docker \
|
||||||
|
&& install -m 0755 /tmp/docker/docker /docker
|
||||||
|
|
||||||
# --- runtime ------------------------------------------------------------------
|
# --- runtime ------------------------------------------------------------------
|
||||||
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
@@ -85,6 +93,8 @@ RUN npm install -g typescript@5 typescript-language-server pyright && npm cache
|
|||||||
COPY --from=server /codecraft /usr/local/bin/codecraft
|
COPY --from=server /codecraft /usr/local/bin/codecraft
|
||||||
COPY --from=web /web/dist /app/web
|
COPY --from=web /web/dist /app/web
|
||||||
COPY --from=cli /out /app/cli
|
COPY --from=cli /out /app/cli
|
||||||
|
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||||
|
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||||
COPY server/ghidra /app/ghidra
|
COPY server/ghidra /app/ghidra
|
||||||
COPY server/resources /app/resources
|
COPY server/resources /app/resources
|
||||||
# The agent's home lives on the data volume: tools it installs in user space
|
# The agent's home lives on the data volume: tools it installs in user space
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00004" src="https://img.shields.io/badge/version-v202611--00004-d69e2e?style=flat-square">
|
<img alt="Version v202611-00007" src="https://img.shields.io/badge/version-v202611--00007-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -103,6 +103,10 @@ copies the workspace, stops CodeCraft, streams the data volume to `<dir>/data`,
|
|||||||
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
|
`docker-compose.yml` (and a `start.sh` for hosts without Compose) with bind mounts, starts the
|
||||||
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
|
published image there and runs `scripts/smoke-test.sh`. The old container is only stopped, so
|
||||||
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
|
you can roll back. `--help` lists the options (owner, port, bind address, dry run).
|
||||||
|
`--tools-dir /mnt/disks/ssd/codecraft` puts everything agents install or download – the agent
|
||||||
|
home (`/data/home`: toolchains, pip/npm/cargo packages, caches), the users' homes and `/tmp` – on
|
||||||
|
a separate disk; the data folder keeps only the database, configuration and backups. Step by step,
|
||||||
|
with rollback and troubleshooting: [docs/MOVING.md](docs/MOVING.md).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -209,6 +213,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
- **Telegram:** connect your own bot (*Settings → Account → Telegram*) and use CodeCraft from
|
||||||
|
Telegram – sessions, modes, models, approvals with buttons, photos and files; `/help` in the
|
||||||
|
bot lists the commands.
|
||||||
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
- **API tokens** (*Settings → Account*) with full or read-only scope for scripts and the terminal client.
|
||||||
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
- **Prometheus:** set `server.metrics_token` and scrape `GET /metrics` with
|
||||||
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
`Authorization: Bearer <token>` – requests, errors, tokens, cost and time per model, provider
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent w
|
|||||||
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
- Use the task tool for broad searches or independent sub-tasks to keep your own context focused.
|
||||||
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it.
|
||||||
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
- Never commit, push or perform destructive/irreversible actions unless the user asked for it.
|
||||||
|
- Git identity and credentials come from the user's settings: never set user.name, user.email or credential helpers yourself (no `git config user.*`, no `-c user.*`).
|
||||||
- Reference code as path:line.
|
- Reference code as path:line.
|
||||||
- Answer in the language the user writes in."#;
|
- Answer in the language the user writes in."#;
|
||||||
|
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Moving CodeCraft to another host (Unraid)
|
||||||
|
|
||||||
|
This guide moves a running installation – Docker volume `codecraft-data` on the current host –
|
||||||
|
to another Docker host that keeps its data in **bind mounts**, for example Unraid. The script
|
||||||
|
`scripts/move-server.sh` does the work; this page explains what goes where, what to prepare and
|
||||||
|
how to check and roll back.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
| Part | Contents | Today (Docker volume) | On the new host |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Data** | database `codecraft.db`, `config.toml` (incl. API keys), daily backups | `codecraft-data:/data` | `<dir>/data` → `/data` |
|
||||||
|
| **Agent installs** | the agent's home `/data/home`: toolchains (Rust, …), pip/pipx/npm/cargo packages, downloaded programs (`~/opt`), caches | inside the volume | `<tools-dir>/home` → `/data/home` |
|
||||||
|
| **Users' homes** | homes of sandboxed users `/data/users/<name>/home` (their installs, git settings) | inside the volume | `<tools-dir>/users` → `/data/users` |
|
||||||
|
| **Temporary files** | `/tmp` of agents, builds, downloads | container layer | `<tools-dir>/tmp` → `/tmp` |
|
||||||
|
| **Workspace** | your projects | `WORKSPACE` from `.env` | `--workspace` path → `/workspace` |
|
||||||
|
| **Image** | the program | registry image | registry image |
|
||||||
|
|
||||||
|
The image never changes: the container runs without root (no `apt`, no `sudo`), so agents can
|
||||||
|
only install into their home directory. With `--tools-dir` everything agents install or
|
||||||
|
download lands on a separate disk (e.g. an SSD); the data folder keeps only the database,
|
||||||
|
configuration and backups. If the tools disk is lost, agents simply install what they need
|
||||||
|
again – nothing important is on it. (Backups contain the database and configuration, not the
|
||||||
|
agent home.)
|
||||||
|
|
||||||
|
Sizes on 2026-11-09 for orientation: agent home 2.2 GB (of which downloaded programs 1.3 GB,
|
||||||
|
Rust toolchain 0.8 GB), database/config/backups about 1.2 GB.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
On the **new host** (Unraid):
|
||||||
|
|
||||||
|
- **SSH with a key** from the current host, without a password prompt. Check from the current
|
||||||
|
host: `ssh root@unraid true` (no output, no question = fine).
|
||||||
|
- **docker**, **rsync** and **tar** available (all three are on Unraid by default).
|
||||||
|
- **Docker Compose** is optional: without it the script starts the container with the generated
|
||||||
|
`start.sh` (Unraid: the "Docker Compose Manager" plugin adds Compose).
|
||||||
|
- The **tools disk** is mounted, e.g. an SSD via Unassigned Devices under `/mnt/disks/ssd`.
|
||||||
|
- **Registry access**: the image comes from `git.familie-zink.org/zink/codecraft`. If the pull is
|
||||||
|
refused, run `docker login git.familie-zink.org` on Unraid once.
|
||||||
|
|
||||||
|
Decide:
|
||||||
|
|
||||||
|
| Question | Option | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Folder for data, compose file and scripts | target `root@host:/dir` | `/mnt/user/appdata/codecraft` |
|
||||||
|
| Folder for agent installs and `/tmp` | `--tools-dir` | `/mnt/disks/ssd/codecraft` |
|
||||||
|
| Your projects | `--workspace` | `/mnt/user/projects` |
|
||||||
|
| Owner of files (the container runs as this user) | `--uid`, `--gid` | Unraid default `99`/`100` (nobody:users); today `1000`/`1000` |
|
||||||
|
| Docker of the new host for agents and terminals | `--docker-sock` | bind mount of `/var/run/docker.sock` plus its group (see below) |
|
||||||
|
| Port and address | `--port`, `--bind` | `8787`; `0.0.0.0` if the reverse proxy runs on another machine or in another Docker network, `127.0.0.1` if it runs on the same host with host networking |
|
||||||
|
|
||||||
|
## Moving
|
||||||
|
|
||||||
|
On the **current host**, in the repository folder (where `.env` is):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Look at the steps first (changes nothing)
|
||||||
|
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||||
|
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||||
|
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock --dry-run
|
||||||
|
|
||||||
|
# 2. Move (same command without --dry-run)
|
||||||
|
scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
|
||||||
|
--workspace /mnt/user/projects --tools-dir /mnt/disks/ssd/codecraft \
|
||||||
|
--uid 99 --gid 100 --bind 0.0.0.0 --docker-sock
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens:
|
||||||
|
|
||||||
|
1. **Checks**: docker/tar/rsync on the target; the target data folder (and `home/`, `users/` in
|
||||||
|
the tools folder) must be empty – the script never overwrites.
|
||||||
|
2. **Workspace, first pass** with rsync while CodeCraft keeps running (the long part).
|
||||||
|
3. **Stops CodeCraft here.** Running agents and terminals end. The container is only stopped,
|
||||||
|
not removed.
|
||||||
|
4. **Data volume** is streamed with tar over SSH into `<dir>/data`; with `--tools-dir` the agent
|
||||||
|
home and the users' homes are then moved to the tools folder.
|
||||||
|
5. **Workspace, second pass**: only what changed since step 2.
|
||||||
|
6. **Owner** of data, tools and workspace set to `--uid:--gid` (needs root on the target).
|
||||||
|
7. **Writes** `<dir>/docker-compose.yml`, `<dir>/.env` (your `.env` with the new paths, port and
|
||||||
|
owner), `<dir>/start.sh` (the same as `docker run`, for hosts without Compose) and
|
||||||
|
`<dir>/docker/seccomp-sandbox.json`.
|
||||||
|
8. **Starts** the image `CODECRAFT_VERSION` from `.env` (Compose, else `start.sh`).
|
||||||
|
9. **Smoke test** on the target (`scripts/smoke-test.sh`): health, version, web UI, terminal
|
||||||
|
client download.
|
||||||
|
|
||||||
|
Afterwards:
|
||||||
|
|
||||||
|
- **Reverse proxy**: point the proxy host for `code.familie-zink.org` (Nginx Proxy Manager) to the
|
||||||
|
new host and port; keep WebSocket support on.
|
||||||
|
- **Check in the web UI**: sign in, open a chat, check the status LEDs (providers, MCP servers),
|
||||||
|
let an agent run `git status` in a project (credentials, identity), open a terminal tab.
|
||||||
|
Models or MCP servers on the old host's LAN address or `localhost` need new addresses.
|
||||||
|
- **Clean up** on the old host when everything works: `docker rm codecraft`, later
|
||||||
|
`docker volume rm codecraft-data`.
|
||||||
|
|
||||||
|
## Docker socket (`--docker-sock`)
|
||||||
|
|
||||||
|
With `--docker-sock` the container gets the new host's Docker: `/var/run/docker.sock` is bind
|
||||||
|
mounted and the container joins the socket's group (the script reads its group id on the
|
||||||
|
target). This is **root-equivalent access to the host**: whoever can start containers can mount
|
||||||
|
any host folder.
|
||||||
|
|
||||||
|
- Admins' agents and terminals (they run without sandbox) can use it.
|
||||||
|
- Sandboxed users do not see the socket (the sandbox does not mount `/var/run`).
|
||||||
|
- `docker` and `docker-compose` commands always need an approval, except in *bypass* mode.
|
||||||
|
- The image contains the Docker CLI (static client); the daemon is the host's.
|
||||||
|
|
||||||
|
The current host has the same setup in its local `docker-compose.override.yml` (group 997).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Nothing is deleted on the old host. On Unraid stop the new container (`docker stop codecraft`),
|
||||||
|
on the old host start the old one (`docker start codecraft`) and point the reverse proxy back.
|
||||||
|
Changes made on the new host in the meantime (chats, settings) are not in the old installation.
|
||||||
|
|
||||||
|
## Running it on the new host
|
||||||
|
|
||||||
|
In `<dir>` on Unraid:
|
||||||
|
|
||||||
|
| Task | With Compose | Without Compose |
|
||||||
|
|---|---|---|
|
||||||
|
| Update | set `CODECRAFT_VERSION` in `.env`, then `docker compose pull && docker compose up -d` | set `CODECRAFT_VERSION` in `.env`, `docker pull …:<version>`, then `./start.sh` |
|
||||||
|
| Restart | `docker compose restart` | `docker restart codecraft` |
|
||||||
|
| Logs | `docker compose logs -f` | `docker logs -f codecraft` |
|
||||||
|
| Check | `bash smoke-test.sh http://127.0.0.1:8787 <version>` | same |
|
||||||
|
|
||||||
|
Releases are published from the development machine with `scripts/release.sh`; the new host
|
||||||
|
only pulls them.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`data on the target is not empty`**: an earlier attempt left files. Move `<dir>/data` (and
|
||||||
|
`home/`, `users/` in the tools folder) away and run the script again.
|
||||||
|
- **Image pull refused**: `docker login git.familie-zink.org` on the target, then
|
||||||
|
`cd <dir> && docker compose up -d` (or `./start.sh`).
|
||||||
|
- **Smoke test fails**: `docker logs codecraft` on the target. The old installation is stopped but
|
||||||
|
intact – `docker start codecraft` on the old host brings it back.
|
||||||
|
- **`could not chown`**: the SSH user is not root; set the owner by hand
|
||||||
|
(`chown -R 99:100 <dir>/data <tools-dir> <workspace>`).
|
||||||
|
- **Permission denied inside the container**: data, tools and workspace must belong to the
|
||||||
|
`UID`/`GID` in `<dir>/.env`.
|
||||||
|
- **Port already in use**: change `PORT` in `<dir>/.env` and start again.
|
||||||
+40
-4
@@ -23,7 +23,14 @@
|
|||||||
#
|
#
|
||||||
# Options:
|
# Options:
|
||||||
# --workspace DIR project folder on the target (default <dir>/workspace)
|
# --workspace DIR project folder on the target (default <dir>/workspace)
|
||||||
|
# --tools-dir DIR separate disk for what agents install and download: the agent home
|
||||||
|
# (/data/home), the users' homes (/data/users) and /tmp are bind
|
||||||
|
# mounts from DIR/home, DIR/users and DIR/tmp (default: inside <dir>/data)
|
||||||
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
|
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
|
||||||
|
# --docker-sock give the container the target's Docker (bind mount of
|
||||||
|
# /var/run/docker.sock plus its group). Root-equivalent access to the
|
||||||
|
# target host for admins' agents and terminals; sandboxed users do not
|
||||||
|
# see it, and docker commands still need approval
|
||||||
# --port N host port on the target (default: PORT of .env)
|
# --port N host port on the target (default: PORT of .env)
|
||||||
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
|
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
|
||||||
# --name NAME container name on the target (default codecraft)
|
# --name NAME container name on the target (default codecraft)
|
||||||
@@ -34,11 +41,13 @@
|
|||||||
# --dry-run show the steps only
|
# --dry-run show the steps only
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft"
|
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
||||||
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--workspace) ws_target="$2"; shift ;;
|
--workspace) ws_target="$2"; shift ;;
|
||||||
|
--tools-dir) tools_dir="$2"; shift ;;
|
||||||
|
--docker-sock) docker_sock=1 ;;
|
||||||
--uid) uid="$2"; shift ;;
|
--uid) uid="$2"; shift ;;
|
||||||
--gid) gid="$2"; shift ;;
|
--gid) gid="$2"; shift ;;
|
||||||
--port) port="$2"; shift ;;
|
--port) port="$2"; shift ;;
|
||||||
@@ -97,10 +106,15 @@ step "Checking source and target"
|
|||||||
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
|
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
|
||||||
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
|
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
|
||||||
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
|
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
|
||||||
echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
|
tools_dir="${tools_dir%/}"
|
||||||
|
echo "target: $host:$dir (data in $dir/data, workspace $ws_target${tools_dir:+, agent installs and /tmp in $tools_dir}), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
|
||||||
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
|
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
|
||||||
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|
||||||
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
|
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
remote "mkdir -p $(q "$tools_dir") && test ! -e $(q "$tools_dir/home") && test ! -e $(q "$tools_dir/users")" \
|
||||||
|
|| fail "$tools_dir already has home/ or users/ – refusing to overwrite (move them away first)"
|
||||||
|
fi
|
||||||
|
|
||||||
step "Copying the workspace (first pass, CodeCraft keeps running)"
|
step "Copying the workspace (first pass, CodeCraft keeps running)"
|
||||||
sync_workspace
|
sync_workspace
|
||||||
@@ -118,9 +132,29 @@ else
|
|||||||
| remote "tar -C $(q "$dir/data") -xpf -"
|
| remote "tar -C $(q "$dir/data") -xpf -"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
step "Moving the agent home and the users' homes to $tools_dir"
|
||||||
|
remote "mkdir -p $(q "$tools_dir/tmp") $(q "$dir/data/home") $(q "$dir/data/users") \
|
||||||
|
&& mv $(q "$dir/data/home") $(q "$tools_dir/home") && mv $(q "$dir/data/users") $(q "$tools_dir/users") \
|
||||||
|
&& mkdir -p $(q "$dir/data/home") $(q "$dir/data/users") && chmod 1777 $(q "$tools_dir/tmp")"
|
||||||
|
fi
|
||||||
|
|
||||||
step "Copying the workspace (changes since the first pass)"
|
step "Copying the workspace (changes since the first pass)"
|
||||||
sync_workspace
|
sync_workspace
|
||||||
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
|
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") ${tools_dir:+$(q "$tools_dir/home") $(q "$tools_dir/users")} 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
|
||||||
|
# Bind mounts for the tools disk and the Docker socket (compose lines and docker run flags).
|
||||||
|
tool_mounts="" tool_flags="" sock_compose="" sock_flags=""
|
||||||
|
if (( docker_sock )); then
|
||||||
|
if (( dry )); then sock_gid=0; else
|
||||||
|
sock_gid=$(remote "stat -c %g /var/run/docker.sock") || fail "no /var/run/docker.sock on the target"
|
||||||
|
fi
|
||||||
|
sock_compose=$(printf ' - /var/run/docker.sock:/var/run/docker.sock\n group_add:\n - "%s"\n' "$sock_gid")
|
||||||
|
sock_flags="-v /var/run/docker.sock:/var/run/docker.sock --group-add $sock_gid "
|
||||||
|
fi
|
||||||
|
if [[ -n "$tools_dir" ]]; then
|
||||||
|
tool_mounts=$(printf ' - %s/home:/data/home\n - %s/users:/data/users\n - %s/tmp:/tmp\n' "$tools_dir" "$tools_dir" "$tools_dir")
|
||||||
|
tool_flags="-v $tools_dir/home:/data/home -v $tools_dir/users:/data/users -v $tools_dir/tmp:/tmp "
|
||||||
|
fi
|
||||||
|
|
||||||
step "Writing docker-compose.yml, .env and start.sh"
|
step "Writing docker-compose.yml, .env and start.sh"
|
||||||
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
|
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
|
||||||
@@ -145,6 +179,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- $dir/data:/data
|
- $dir/data:/data
|
||||||
- \${WORKSPACE}:/workspace
|
- \${WORKSPACE}:/workspace
|
||||||
|
$tool_mounts
|
||||||
|
$sock_compose
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
shm_size: "1gb"
|
shm_size: "1gb"
|
||||||
@@ -163,7 +199,7 @@ val() { sed -n "s/^\$1=//p" .env | tail -1; }
|
|||||||
docker rm -f $name >/dev/null 2>&1 || true
|
docker rm -f $name >/dev/null 2>&1 || true
|
||||||
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
|
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
|
||||||
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
|
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
|
||||||
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\
|
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" $tool_flags$sock_flags\\
|
||||||
--add-host host.docker.internal:host-gateway --shm-size 1g \\
|
--add-host host.docker.internal:host-gateway --shm-size 1g \\
|
||||||
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
|
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
|
||||||
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
|
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ shell-words = "1"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "form", "multipart"] }
|
||||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
+62
-16
@@ -138,6 +138,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||||
|
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||||
.route("/templates", get(crate::cli_api::get_templates))
|
.route("/templates", get(crate::cli_api::get_templates))
|
||||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||||
.route("/cli/config", get(crate::cli_api::config))
|
.route("/cli/config", get(crate::cli_api::config))
|
||||||
@@ -327,6 +328,44 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
|
|||||||
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Telegram bot of the user: bot name, pairing state, errors (the token stays hidden).
|
||||||
|
async fn get_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let s = crate::telegram::load(&app, &user.id);
|
||||||
|
let (running, error) = crate::telegram::status(&user.id);
|
||||||
|
Ok(Json(json!({
|
||||||
|
"configured": !s.token.is_empty(),
|
||||||
|
"bot": s.bot,
|
||||||
|
"paired": s.chat_id.is_some(),
|
||||||
|
"pair_code": if s.chat_id.is_none() { s.pair_code } else { String::new() },
|
||||||
|
"running": running,
|
||||||
|
"error": error,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TelegramBody {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect a bot: check the token, start polling and create a pairing code.
|
||||||
|
async fn put_telegram(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<TelegramBody>) -> ApiResult {
|
||||||
|
let token = crate::telegram::extract_token(&b.token)
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "that is not a bot token – it looks like 123456789:AAH… (paste only the token from @BotFather)".into()))?;
|
||||||
|
let bot = crate::telegram::check_token(&token).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("token rejected by Telegram: {e:#}")))?;
|
||||||
|
let s = crate::telegram::Settings { token, bot, pair_code: crate::telegram::new_pair_code(), ..Default::default() };
|
||||||
|
app.store.set_user_setting(&user.id, "telegram", &s)?;
|
||||||
|
crate::telegram::restart(&app, &user.id);
|
||||||
|
app.audit(Some(&user), "telegram.connect", &s.bot, "");
|
||||||
|
get_telegram(Extension(user), State(app)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_telegram(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
crate::telegram::stop(&user.id);
|
||||||
|
app.store.set_user_setting(&user.id, "telegram", &crate::telegram::Settings::default())?;
|
||||||
|
app.audit(Some(&user), "telegram.disconnect", "", "");
|
||||||
|
Ok(Json(json!({"ok": true})))
|
||||||
|
}
|
||||||
|
|
||||||
/// The user's personal modes (name -> settings).
|
/// The user's personal modes (name -> settings).
|
||||||
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||||
@@ -858,41 +897,48 @@ fn set_turn_effort(app: &App, id: &str, effort: Option<&str>) -> Result<(), ApiE
|
|||||||
|
|
||||||
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
async fn prompt(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<PromptBody>) -> ApiResult {
|
||||||
let session = writable(&app, &user, &id)?;
|
let session = writable(&app, &user, &id)?;
|
||||||
let text = body.text.trim().to_string();
|
send_prompt(&app, session, body.text, body.images, body.effort.as_deref()).map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a user message for a session (web UI and Telegram): built-in commands,
|
||||||
|
/// slash command expansion, then start or queue the turn.
|
||||||
|
pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text: String, images: Vec<ImageData>, effort: Option<&str>) -> Result<Value, ApiError> {
|
||||||
|
let id = session.id.clone();
|
||||||
|
let text = text.trim().to_string();
|
||||||
// Built-in commands.
|
// Built-in commands.
|
||||||
if text == "/compact" {
|
if text == "/compact" {
|
||||||
if app.is_busy(&id) {
|
if app.is_busy(&id) {
|
||||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
}
|
}
|
||||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||||
return Ok(Json(json!({"ok": true, "command": "compact"})));
|
return Ok(json!({"ok": true, "command": "compact"}));
|
||||||
}
|
}
|
||||||
let cwd = PathBuf::from(&session.cwd);
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||||
let mut prompt_text = text.clone();
|
let mut prompt_text = text.clone();
|
||||||
if text.starts_with('/') {
|
if text.starts_with('/') {
|
||||||
if let Some((expanded, agent, clean_room)) = prompts::expand_command(&app, &root, &cwd, &text) {
|
if let Some((expanded, agent, clean_room)) = prompts::expand_command(app, &root, &cwd, &text) {
|
||||||
prompt_text = expanded;
|
prompt_text = expanded;
|
||||||
if agent.is_some() || clean_room.is_some() {
|
if agent.is_some() || clean_room.is_some() {
|
||||||
let mut s = session.clone();
|
app.update_session(&id, |s| {
|
||||||
if let Some(a) = agent {
|
if let Some(a) = agent {
|
||||||
s.agent = a;
|
s.agent = a;
|
||||||
}
|
}
|
||||||
if clean_room.is_some() {
|
if clean_room.is_some() {
|
||||||
s.clean_room = clean_room;
|
s.clean_room = clean_room;
|
||||||
}
|
}
|
||||||
app.save_session(s)?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set_turn_effort(&app, &id, body.effort.as_deref())?;
|
set_turn_effort(app, &id, effort)?;
|
||||||
let mut parts = vec![Part::Text { text: prompt_text }];
|
let mut parts = vec![Part::Text { text: prompt_text }];
|
||||||
for image in body.images {
|
for image in images {
|
||||||
parts.push(Part::Image { image });
|
parts.push(Part::Image { image });
|
||||||
}
|
}
|
||||||
match runner::submit(app.clone(), id, parts, "user")? {
|
match runner::submit(app.clone(), id, parts, "user")? {
|
||||||
runner::Submitted::Started => Ok(Json(json!({"ok": true}))),
|
runner::Submitted::Started => Ok(json!({"ok": true})),
|
||||||
runner::Submitted::Queued(item) => Ok(Json(json!({"ok": true, "queued": item}))),
|
runner::Submitted::Queued(item) => Ok(json!({"ok": true, "queued": item})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +962,7 @@ async fn flush_queue(Extension(user): Extension<User>, State(app): State<AppStat
|
|||||||
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
Ok(Json(json!({"started": runner::flush_queue(app.clone(), &id)?})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_compact(app: AppState, id: String) {
|
pub(crate) async fn do_compact(app: AppState, id: String) {
|
||||||
let cancel = tokio_util::sync::CancellationToken::new();
|
let cancel = tokio_util::sync::CancellationToken::new();
|
||||||
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
app.with_runtime(&id, |r| r.cancel = Some(cancel.clone()));
|
||||||
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
app.emit(Event::SessionStatus { session_id: id.clone(), busy: true });
|
||||||
|
|||||||
+7
-2
@@ -44,6 +44,8 @@ tokio::task_local! {
|
|||||||
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
static SANDBOX: Option<crate::sandbox::SandboxSpec>;
|
||||||
/// The user's git config (credentials, identity) for unsandboxed runs.
|
/// The user's git config (credentials, identity) for unsandboxed runs.
|
||||||
static GITCONFIG: Option<std::path::PathBuf>;
|
static GITCONFIG: Option<std::path::PathBuf>;
|
||||||
|
/// The user's commit identity (overrides a repository's own user.name/email).
|
||||||
|
static GITENV: Vec<(String, String)>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
||||||
@@ -63,6 +65,7 @@ async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result<Out, ApiError> {
|
|||||||
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
if let Some(cfg) = GITCONFIG.try_with(|g| g.clone()).ok().flatten() {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
}
|
}
|
||||||
|
c.envs(GITENV.try_with(|e| e.clone()).unwrap_or_default());
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -171,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
|
|||||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, handle_inner(user, app, b))).await
|
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||||
|
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
||||||
@@ -388,8 +392,9 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
|||||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||||
let cfg = crate::gitcreds::global_config(app, user);
|
let cfg = crate::gitcreds::global_config(app, user);
|
||||||
|
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||||
let s = session.clone();
|
let s = session.clone();
|
||||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, worktree_inner(s, action))).await?;
|
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||||
session.cwd = cwd.to_string();
|
session.cwd = cwd.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,24 @@ pub struct GitSettings {
|
|||||||
pub credentials: Vec<GitCredential>,
|
pub credentials: Vec<GitCredential>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's commit identity as environment variables. They take precedence over a
|
||||||
|
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
||||||
|
/// the git panel always carry the identity from the settings.
|
||||||
|
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||||
|
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
||||||
|
let mut env = Vec::new();
|
||||||
|
let (name, email) = (s.name.trim(), s.email.trim());
|
||||||
|
if !name.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
||||||
|
}
|
||||||
|
if !email.is_empty() {
|
||||||
|
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
||||||
|
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||||
app.data_dir().join("users").join(&user.username).join("home")
|
app.data_dir().join("users").join(&user.username).join("home")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ mod runner;
|
|||||||
mod sandbox;
|
mod sandbox;
|
||||||
mod shells;
|
mod shells;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod telegram;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod tools;
|
mod tools;
|
||||||
mod types;
|
mod types;
|
||||||
@@ -73,6 +74,7 @@ async fn main() -> Result<()> {
|
|||||||
health::spawn_checker(app.clone());
|
health::spawn_checker(app.clone());
|
||||||
digest::spawn(app.clone());
|
digest::spawn(app.clone());
|
||||||
budget::spawn_alerts(app.clone());
|
budget::spawn_alerts(app.clone());
|
||||||
|
telegram::spawn(app.clone());
|
||||||
backup::spawn(app.clone());
|
backup::spawn(app.clone());
|
||||||
let router = api::router(app);
|
let router = api::router(app);
|
||||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||||
|
|||||||
+25
-1
@@ -18,6 +18,8 @@ pub struct SandboxSpec {
|
|||||||
pub roots: Vec<PathBuf>,
|
pub roots: Vec<PathBuf>,
|
||||||
/// Host directory mounted as the user's home.
|
/// Host directory mounted as the user's home.
|
||||||
pub home: PathBuf,
|
pub home: PathBuf,
|
||||||
|
/// Extra environment (the user's git identity).
|
||||||
|
pub env: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SandboxSpec {
|
impl SandboxSpec {
|
||||||
@@ -69,7 +71,7 @@ pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
|
|||||||
}
|
}
|
||||||
let home = app.data_dir().join("users").join(&user.username).join("home");
|
let home = app.data_dir().join("users").join(&user.username).join("home");
|
||||||
let _ = std::fs::create_dir_all(&home);
|
let _ = std::fs::create_dir_all(&home);
|
||||||
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home })
|
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
/// Build a command that runs `program args` inside the sandbox in `cwd`.
|
||||||
@@ -148,9 +150,31 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
|||||||
] {
|
] {
|
||||||
push(&[&"--setenv", &k, &v]);
|
push(&[&"--setenv", &k, &v]);
|
||||||
}
|
}
|
||||||
|
for (k, v) in &spec.env {
|
||||||
|
push(&[&"--setenv", k, v]);
|
||||||
|
}
|
||||||
push(&[&"--chdir", &cwd, &program]);
|
push(&[&"--chdir", &cwd, &program]);
|
||||||
for a in args {
|
for a in args {
|
||||||
push(&[a]);
|
push(&[a]);
|
||||||
}
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_identity_reaches_the_sandbox() {
|
||||||
|
let spec = SandboxSpec {
|
||||||
|
username: "ann".into(),
|
||||||
|
roots: vec![],
|
||||||
|
home: PathBuf::from("/tmp/ann"),
|
||||||
|
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
|
||||||
|
};
|
||||||
|
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||||
|
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
|
||||||
|
assert_eq!(args[i - 1], "--setenv");
|
||||||
|
assert_eq!(args[i + 1], "Ann Example");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -157,7 +157,12 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
|
|||||||
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
pty_process::Command::new("bwrap").env_clear().args(crate::sandbox::bwrap_args(&spec, cwd, &[], false, shell[0], &shell[1..]))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let c = pty_process::Command::new(shell[0]).args(&shell[1..]).current_dir(cwd).env("TERM", "xterm-256color").env("CODECRAFT", "1");
|
let c = pty_process::Command::new(shell[0])
|
||||||
|
.args(&shell[1..])
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env("TERM", "xterm-256color")
|
||||||
|
.env("CODECRAFT", "1")
|
||||||
|
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||||
match crate::gitcreds::global_config(app, user) {
|
match crate::gitcreds::global_config(app, user) {
|
||||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||||
None => c,
|
None => c,
|
||||||
|
|||||||
@@ -150,8 +150,11 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
|||||||
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||||
// The session owner's git credentials and identity.
|
// The session owner's git credentials and identity.
|
||||||
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten());
|
||||||
if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) {
|
if let Some(u) = owner {
|
||||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) {
|
||||||
|
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||||
|
}
|
||||||
|
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||||
}
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
|||||||
import { ApiTokens } from "./ApiTokens";
|
import { ApiTokens } from "./ApiTokens";
|
||||||
import { CliInstall } from "./CliInstall";
|
import { CliInstall } from "./CliInstall";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
|
import { Telegram } from "./Telegram";
|
||||||
|
|
||||||
export function Account() {
|
export function Account() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -51,6 +52,7 @@ export function Account() {
|
|||||||
<Memory />
|
<Memory />
|
||||||
<GitAccount />
|
<GitAccount />
|
||||||
<CliInstall />
|
<CliInstall />
|
||||||
|
<Telegram />
|
||||||
<ApiTokens />
|
<ApiTokens />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { del, put } from "../../api";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { useFetch } from "../../hooks";
|
||||||
|
import { useT } from "../../i18n";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
configured: boolean;
|
||||||
|
bot: string;
|
||||||
|
paired: boolean;
|
||||||
|
pair_code: string;
|
||||||
|
running: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect a personal Telegram bot as a chat interface. */
|
||||||
|
export function Telegram() {
|
||||||
|
const t = useT();
|
||||||
|
const { data, reload } = useFetch<Status>("/account/telegram");
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await put("/account/telegram", { token: token.trim() });
|
||||||
|
setToken("");
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const disconnect = async () => {
|
||||||
|
if (!(await confirmDialog({ message: t("telegram.disconnectConfirm"), danger: true, confirmLabel: t("telegram.disconnect") }))) return;
|
||||||
|
await del("/account/telegram").catch((e) => toast(e));
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<Icon name="share" /> {t("telegram.title")}
|
||||||
|
<span className="spacer" />
|
||||||
|
{data?.configured && (
|
||||||
|
<button className="small ghost danger" onClick={disconnect}>
|
||||||
|
{t("telegram.disconnect")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 10 }}>
|
||||||
|
{t("telegram.intro")}
|
||||||
|
</div>
|
||||||
|
{data && !data.configured && (
|
||||||
|
<>
|
||||||
|
<ol className="cli-steps">
|
||||||
|
<li>{t("telegram.step1")}</li>
|
||||||
|
<li>{t("telegram.step2")}</li>
|
||||||
|
</ol>
|
||||||
|
<div className="row" style={{ gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="telegram-bot-token"
|
||||||
|
value={token}
|
||||||
|
placeholder="123456789:AAH…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="mono"
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button className="primary" onClick={() => void connect()} disabled={busy || !token.trim()}>
|
||||||
|
{busy ? t("telegram.checking") : t("telegram.connect")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data?.configured && (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
|
<span className="chip">@{data.bot}</span>
|
||||||
|
<span className={`chip ${data.running && !data.error ? "ok" : "warn"}`}>{data.running && !data.error ? t("telegram.running") : t("telegram.stopped")}</span>
|
||||||
|
{data.paired && <span className="chip ok">{t("telegram.paired")}</span>}
|
||||||
|
</div>
|
||||||
|
{!data.paired && data.pair_code && (
|
||||||
|
<div className="notice" style={{ marginTop: 10 }}>
|
||||||
|
{t("telegram.pairHint", { bot: data.bot })}{" "}
|
||||||
|
<a href={`https://t.me/${data.bot}?start=${data.pair_code}`} target="_blank" rel="noreferrer">
|
||||||
|
t.me/{data.bot}
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<code className="token-value">/start {data.pair_code}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.error && <div className="notice err" style={{ marginTop: 10 }}>{data.error}</div>}
|
||||||
|
{data.paired && <div className="hint" style={{ marginTop: 10 }}>{t("telegram.commands")}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -405,6 +405,19 @@ export const de: Messages = {
|
|||||||
"tokens.title": "API-Tokens",
|
"tokens.title": "API-Tokens",
|
||||||
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||||
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||||
|
"telegram.title": "Telegram",
|
||||||
|
"telegram.intro": "CodeCraft über Telegram mit deinem eigenen Bot nutzen: Nachrichten gehen an deine aktuelle Sitzung, Befehle wechseln Sitzungen, Modi und Modelle, Freigaben und Rückfragen kommen mit Knöpfen. Nur dein Telegram-Konto kann den Bot nutzen.",
|
||||||
|
"telegram.step1": "In Telegram @BotFather öffnen, /newbot senden und den Schritten folgen.",
|
||||||
|
"telegram.step2": "Das Token von BotFather hier einfügen.",
|
||||||
|
"telegram.connect": "Verbinden",
|
||||||
|
"telegram.checking": "Prüfe…",
|
||||||
|
"telegram.running": "läuft",
|
||||||
|
"telegram.stopped": "läuft nicht",
|
||||||
|
"telegram.paired": "gekoppelt",
|
||||||
|
"telegram.pairHint": "Letzter Schritt: deinen Bot öffnen und den Code senden (oder den Link antippen) – danach bedient er nur noch dein Telegram-Konto:",
|
||||||
|
"telegram.commands": "In Telegram: /help zeigt die Befehle – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||||
|
"telegram.disconnect": "Trennen",
|
||||||
|
"telegram.disconnectConfirm": "Telegram-Bot trennen? Er antwortet dann nicht mehr; mit seinem Token kannst du ihn wieder verbinden.",
|
||||||
"tokens.copy": "Kopieren",
|
"tokens.copy": "Kopieren",
|
||||||
"tokens.copied": "Kopiert",
|
"tokens.copied": "Kopiert",
|
||||||
"tokens.name": "Name",
|
"tokens.name": "Name",
|
||||||
|
|||||||
@@ -404,6 +404,19 @@ export const en = {
|
|||||||
"tokens.title": "API tokens",
|
"tokens.title": "API tokens",
|
||||||
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||||
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||||
|
"telegram.title": "Telegram",
|
||||||
|
"telegram.intro": "Use CodeCraft from Telegram with your own bot: messages go to your current session, commands switch sessions, modes and models, approvals and questions come with buttons. Only your Telegram account can use the bot.",
|
||||||
|
"telegram.step1": "In Telegram, open @BotFather, send /newbot and follow the steps.",
|
||||||
|
"telegram.step2": "Paste the token BotFather gives you here.",
|
||||||
|
"telegram.connect": "Connect",
|
||||||
|
"telegram.checking": "Checking…",
|
||||||
|
"telegram.running": "running",
|
||||||
|
"telegram.stopped": "not running",
|
||||||
|
"telegram.paired": "paired",
|
||||||
|
"telegram.pairHint": "Last step: open your bot and send the code (or tap the link) – from then on only your Telegram account is served:",
|
||||||
|
"telegram.commands": "In Telegram: /help lists the commands – /sessions, /new, /mode, /model, /stop, /status, /costs …",
|
||||||
|
"telegram.disconnect": "Disconnect",
|
||||||
|
"telegram.disconnectConfirm": "Disconnect the Telegram bot? It stops answering; you can connect it again with its token.",
|
||||||
"tokens.copy": "Copy",
|
"tokens.copy": "Copy",
|
||||||
"tokens.copied": "Copied",
|
"tokens.copied": "Copied",
|
||||||
"tokens.name": "Name",
|
"tokens.name": "Name",
|
||||||
|
|||||||
@@ -3,6 +3,18 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00007": {
|
||||||
|
en: ["Telegram: connect your own bot under Settings → Account → Telegram and chat with CodeCraft from your phone"],
|
||||||
|
de: ["Telegram: eigenen Bot unter Einstellungen → Konto → Telegram verbinden und von unterwegs mit CodeCraft chatten"],
|
||||||
|
},
|
||||||
|
"202611-00006": {
|
||||||
|
en: ["Docker CLI in the image – with the host's Docker socket mounted, admins' agents and terminals can use Docker"],
|
||||||
|
de: ["Docker-CLI im Image – mit eingehängtem Docker-Socket können Admin-Agents und -Terminals Docker nutzen"],
|
||||||
|
},
|
||||||
|
"202611-00005": {
|
||||||
|
en: ["Agents commit with the git name and e-mail from your settings – also in repositories with their own identity"],
|
||||||
|
de: ["Agents committen mit Git-Name und -E-Mail aus deinen Einstellungen – auch in Repos mit eigener Identität"],
|
||||||
|
},
|
||||||
"202611-00004": {
|
"202611-00004": {
|
||||||
en: [
|
en: [
|
||||||
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
"Chats side by side: “Open beside” in a chat's menu in the sidebar",
|
||||||
|
|||||||
Reference in new issue
Block a user