Compare commits

...
Author SHA1 Message Date
Richard Zink b7c8741a7d Release v202611-00006 2026-11-09 15:39:50 +01:00
Richard ZinkandClaude Opus 5.5 bf99292f43 Docker CLI in the image (static client for a mounted host socket)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:39:48 +01:00
Richard ZinkandClaude Opus 5.5 36de035dd8 move-server.sh: --docker-sock to give the container the target's Docker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:30:29 +01:00
Richard ZinkandClaude Opus 5.5 b59c5b8890 docs: guide for moving CodeCraft to another host (Unraid, bind mounts, tools disk)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:56:23 +01:00
Richard ZinkandClaude Opus 5.5 eef17eb591 move-server.sh: --tools-dir for agent installs and /tmp on a separate disk
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:54:21 +01:00
Richard Zink 6b1d7f91b8 Release v202611-00005 2026-11-09 12:24:12 +01:00
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the
sandbox), so a repository's own user.name/user.email no longer wins; tell agents
not to change the git identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:22:25 +01:00
12 changed files with 286 additions and 11 deletions

No files matched your search

+18
View File
@@ -3,6 +3,24 @@
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-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
+10
View File
@@ -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
+5 -1
View File
@@ -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-00006" src="https://img.shields.io/badge/version-v202611--00006-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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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 \\
+7 -2
View File
@@ -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();
} }
+18
View File
@@ -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")
} }
+25 -1
View File
@@ -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");
}
}
+6 -1
View File
@@ -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,
+5 -2
View File
@@ -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
} }
+8
View File
@@ -3,6 +3,14 @@
* 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-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",