Skip to content

Commit f9d40c7

Browse files
committed
feat: add portable environment bundles
- support incremental init and rootfs/bundle export-import - preserve hardlinks and metadata across environment migration - add a rootless rollback demo and sharpen project positioning
1 parent f96c96e commit f9d40c7

22 files changed

Lines changed: 1799 additions & 588 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
55
[Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- **Incremental init** (`agentenv init --sync --from <dir|/>` / `--tarball`):
11+
overlays new/changed files from the source onto the current environment and
12+
commits a **child** of HEAD, instead of the one-shot root-node behavior. This
13+
folds in packages/config that landed on the container's host `/` *after* the
14+
first seed (an entrypoint step or `docker exec`), which the sandboxed agent —
15+
running in `work/current` — otherwise never sees. `--delete` additionally
16+
mirrors removals (drops files absent from the source); the default is additive.
17+
- **Environment export/import** (`agentenv export` / `agentenv import`):
18+
- `export [ref] --format rootfs` — a node's rootfs as a flat `.tar(.gz)`, to
19+
`-o <file>` or `-o -` (stdout), for `docker import -` / `FROM scratch; ADD`.
20+
Cleaner than `docker commit`, which would also capture agentenv's node store.
21+
- `export --format agentenv [--all]` — an **agentenv bundle**: a self-describing
22+
archive (`manifest.json` + per-node rootfs) that moves a whole environment
23+
between agentenv instances. `--all` carries the entire commit-DAG, tags and
24+
history; without it, a single self-contained snapshot.
25+
- `import <tar|URL>` — auto-detects the input: a flat rootfs tar becomes one
26+
node under HEAD; a snapshot bundle adds its node; a whole-repo bundle restores
27+
the full DAG into an empty `AGENTENV_ROOT`.
28+
- New `image.TarDir`/`TarInto` (the inverse of `extractTar`, hardlink-aware and
29+
PAX-timestamped so bundles reproduce the on-disk store exactly) and
30+
`internal/repo/bundle.go` (the bundle manifest + pack/restore).
31+
732
## [0.3.1] - 2026-07-02
833

934
### Added

DESIGN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ agent from the restored environment.
1515
- **Language**: Go. **No mandatory base image** — bring your own rootfs via
1616
`init --from <dir|/>` (the common path: wrap an existing container image)
1717
or `init --tarball <path|URL>` (for air-gapped / scripted seeding).
18+
`init --sync` re-seeds incrementally after start; `export`/`import` move an
19+
environment out to a Docker image (flat rootfs tar) or between agentenv
20+
instances (an **agentenv bundle** = `manifest.json` + per-node rootfs,
21+
hardlink-shared and PAX-timestamped so it faithfully reproduces the store).
1822
- **Minimal deps**:
1923
- `containerd/btrfs/v2` — cgo, **only** when built `-tags btrfs`.
2024
- `creack/pty` + `golang.org/x/sys` + `golang.org/x/term` — interactive
@@ -116,6 +120,9 @@ btrfs backend, when used, requires `AGENTENV_ROOT` to be on a btrfs fs.
116120
| Command | Effect |
117121
|--------------------|--------|
118122
| `init --from <dir>` / `init --tarball <p>` | seed root from a directory tree (one-time copy) or extract a `.tar(.gz)`; freeze as the root node |
123+
| `init --sync --from <dir>` / `--tarball <p>` | incremental: overlay the source onto `work/current` and freeze a **child** of HEAD (folds in files added to the host after the first seed); `--delete` also mirrors removals |
124+
| `export [ref] --format rootfs\|agentenv [--all]` | write a node's rootfs as a flat tar (for `docker import` / migration), or an agentenv **bundle** (`--all` = whole DAG + tags + history; else one self-contained snapshot) |
125+
| `import <tar\|URL>` | auto-detected: flat rootfs → one node under HEAD; snapshot bundle → its node; whole-repo bundle → restore the full DAG into an empty root |
119126
| `supervise -- <cmd>` | start the inner agent under auto-capture; survives rollbacks (relaunches the agent from the restored env) |
120127
| `daemon` | serve the newline-JSON protocol on the unix socket (for orchestrators / `ctl`) |
121128
| `mcp` | MCP server over stdio (Claude Code / any MCP host); bridges tool calls to the daemon socket |

Makefile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#
44
# Cheat sheet:
55
# make help list every target with a short description
6+
# make demo watch agentenv undo whole-system changes, rootless
67
# make build cross-compile a Linux static binary into ./bin/
78
# make test cross-platform unit tests (dag, image, ...)
89
# make vet gofmt check + go vet
@@ -13,13 +14,20 @@
1314

1415
GOOS_LINUX ?= linux
1516
GOARCH ?= $(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/;s/arm64/arm64/')
16-
GOPROXY ?= $(shell go env GOPROXY)
17+
GOPROXY ?= $(shell if command -v go >/dev/null 2>&1; then go env GOPROXY; else printf '%s' 'https://proxy.golang.org,direct'; fi)
1718

18-
.PHONY: help build test vet verify-rootless verify-btrfs verify-supervise verify-mcp verify-rollback openapi-snapshot dev-shell dev-shell-stop clean
19+
.PHONY: help demo build test vet verify-rootless verify-btrfs verify-supervise verify-mcp verify-rollback openapi-snapshot dev-shell dev-shell-stop clean
1920

2021
help:
2122
@awk 'BEGIN{FS=":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-22s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
2223

24+
demo: ## rootless Docker demo: break the whole environment, then rewind it
25+
docker run --rm --user 1001:1001 --security-opt seccomp=unconfined \
26+
-v "$$PWD":/src:ro \
27+
-e GOFLAGS=-mod=mod -e GOPROXY="$(GOPROXY)" \
28+
-e CGO_ENABLED=0 -e GOCACHE=/tmp/gocache -e GOMODCACHE=/tmp/gomodcache \
29+
-w /src golang:1.26 bash /src/scripts/demo-rootless.sh
30+
2331
build: ## cross-compile a Linux static binary (./bin/agentenv-<arch>)
2432
@mkdir -p bin
2533
GOOS=$(GOOS_LINUX) GOARCH=$(GOARCH) CGO_ENABLED=0 go build -o bin/agentenv-$(GOARCH) .

README.md

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55
[![Go Reference](https://pkg.go.dev/badge/github.com/css521/agentenv.svg)](https://pkg.go.dev/github.com/css521/agentenv)
66

7-
A **self-hostable, rewindable virtual environment for agents** that runs even in a
8-
locked-down Kubernetes pod (non-root, no `privileged`, no special capabilities).
9-
The environment versions itself automatically (zero agent intrusion), can roll
10-
back to **any node**, and can **branch** so an agent explores several approaches in
11-
parallel branches and keeps the winner.
7+
**Git-style time travel for AI agents.** agentenv automatically snapshots an
8+
agent's entire Linux environment — source files, installed packages, tools, and
9+
system configuration — so it can rewind to any past node or fork several
10+
approaches and keep the winner.
11+
12+
It is self-hosted and rootless: on Linux/Kubernetes nodes that allow
13+
unprivileged user namespaces, the default backend runs as a non-root user
14+
without `privileged`, extra Linux capabilities, `/dev/kvm`, btrfs, or FUSE. The
15+
agent runs unmodified, and MCP lets it inspect and rewind its own environment.
1216

1317
![Claude Code deletes its own binary, then rolls the environment back via MCP and recovers it](./docs/claude-rewind.gif)
1418

@@ -19,12 +23,27 @@ parallel branches and keeps the winner.
1923
> Reproducible from [`examples/claude-code/`](./examples/claude-code/); re-record
2024
> the GIF with [`scripts/record-claude-tui.sh`](./scripts/record-claude-tui.sh).
2125
26+
## Try the rollback
27+
28+
Run a real rootless demo in Docker. It creates a working system binary, deletes
29+
it and changes `/etc`, then rewinds the whole environment and verifies both
30+
changes were undone. No `privileged` container or extra capabilities are used.
31+
32+
```bash
33+
git clone https://github.com/css521/agentenv.git
34+
cd agentenv
35+
make demo
36+
```
37+
38+
The first run downloads the Go build image and a minimal Ubuntu rootfs. The demo
39+
runs as uid 1001 and uses the same copy backend intended for restricted pods.
40+
2241
See [`DESIGN.md`](./DESIGN.md) for the architecture.
2342

2443
## Install
2544

2645
```bash
27-
# Default static binary (copy/rootless backend) — runs anywhere, incl. restricted pods:
46+
# Linux static binary (copy/rootless backend) — including restricted pods:
2847
go install github.com/css521/agentenv@latest
2948

3049
# From source:
@@ -34,7 +53,7 @@ CGO_ENABLED=1 go build -tags btrfs -o agentenv . # optional btrfs fast path
3453
```
3554

3655
Prebuilt binaries are attached to each [release](https://github.com/css521/agentenv/releases).
37-
Multi-arch container images at `ghcr.io/css521/agentenv` (`:latest` or `:vX.Y.Z`).
56+
Multi-arch container images are at `ghcr.io/css521/agentenv` (`:latest` or `:X.Y.Z`).
3857

3958
## Why it's different
4059

@@ -95,6 +114,16 @@ agentenv upgrade [--version X] zero-downtime hot-upgrade of a running daemon (r
95114
# setup / history / inspection
96115
agentenv init --from <dir|/> | --tarball <path|URL>
97116
seed root from an existing dir, or extract a tar(.gz)
117+
agentenv init --sync --from <dir|/> | --tarball <p> [-m msg] [--delete]
118+
incremental: overlay changes onto the env, commit a child node
119+
(folds in files added to the host AFTER the first seed);
120+
--delete also removes files gone from the source (mirror)
121+
agentenv export [ref] -o <file|-> [--format rootfs|agentenv] [--all] [--gzip]
122+
rootfs = flat tar of a node (for `docker import` / migration);
123+
agentenv = self-describing bundle: --all = whole repo + history,
124+
else a single self-contained snapshot
125+
agentenv import <tar|URL> [-m msg]
126+
bring in a rootfs tar OR an agentenv bundle (auto-detected)
98127
agentenv commit -m "msg" manual snapshot (auto-capture usually does this)
99128
agentenv checkout <ref> roll the whole env back to any node (accepts tag/prefix)
100129
agentenv delete <ref> remove a node (children re-parent; HEAD/only-node refused)
@@ -185,6 +214,45 @@ kubectl exec my-agent -- agentenv ctl log
185214
kubectl exec my-agent -- agentenv ctl checkout <node-id> # roll the whole env back
186215
```
187216

217+
## Incremental init, migration & sharing
218+
219+
Two lifecycle needs beyond the one-time `init`:
220+
221+
**Fold in files added after startup.** The first `init --from /` is a one-time
222+
copy, so packages/config downloaded into the container's real `/` *afterwards*
223+
(an entrypoint step, or a `docker exec`) never reach the sandboxed env. Re-run
224+
init with `--sync` to overlay them and commit a child node:
225+
226+
```bash
227+
agentenv init --sync --from / # pick up new/changed files on the host
228+
agentenv init --sync --from / --delete # also drop files removed from the source (mirror)
229+
```
230+
231+
**Migrate the environment to another Docker image** — export a node's rootfs as a
232+
flat tar (no `docker commit`, which would also capture agentenv's own node store):
233+
234+
```bash
235+
agentenv export -o - | docker import - my-image:tag # stream straight into docker
236+
agentenv export -o rootfs.tar # or, in a Dockerfile:
237+
# FROM scratch
238+
# ADD rootfs.tar /
239+
```
240+
241+
**Move a whole environment (with history) between agentenv instances** — the
242+
agentenv bundle format carries the commit-DAG, tags and every node's rootfs:
243+
244+
```bash
245+
agentenv export --format agentenv --all -o env.agentenv # whole repo + history
246+
agentenv export --format agentenv -o snap.agentenv # one self-contained snapshot
247+
248+
AGENTENV_ROOT=/fresh agentenv import env.agentenv # restore into an empty root
249+
agentenv import snap.agentenv # or add a snapshot under HEAD
250+
```
251+
252+
`import` auto-detects the input: a plain rootfs tar becomes one node under HEAD; a
253+
snapshot bundle adds its node; a whole-repo bundle restores the full DAG into an
254+
empty `AGENTENV_ROOT`.
255+
188256
## Agent integration (socket API)
189257

190258
`agentenv daemon` serves a newline-delimited JSON protocol over a unix socket, so

design_zh.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# agentenv — 给 AI Agent 用的"后悔药"环境
2+
3+
## 一句话说清楚
4+
5+
agentenv 是一个沙箱工具。AI Agent 在里面干活时,**环境会自动保存每一步的快照**。搞砸了?一条命令就能回到之前任意一个"存档点",就像游戏里的存档/读档。
6+
7+
Agent 本身完全不需要做任何适配——它甚至不知道 agentenv 的存在。
8+
9+
---
10+
11+
## 它解决什么问题?
12+
13+
AI Agent 在执行编程任务时,经常需要安装依赖、修改配置文件、执行 shell 命令。这些操作有可能把环境搞坏——装错了包、改坏了配置、执行了有副作用的命令。
14+
15+
传统方案是重建整个环境,代价很高。agentenv 的做法是:**环境随时可以"倒带"回去**,就像录像机的回放功能。
16+
17+
---
18+
19+
## 核心概念
20+
21+
### 快照(Snapshot)
22+
23+
每次文件系统发生变化(不管是 Agent 执行命令还是手动编辑文件),agentenv 都会自动拍一个快照。快照是只读的,保存了那个时刻文件系统的完整状态。
24+
25+
你可以把它想象成 Git 的 commit,但记录的不是代码仓库,而是**整个操作系统的文件系统**
26+
27+
### 回滚(Rollback)
28+
29+
回滚就是"读档":把文件系统恢复到某个快照的状态,同时终止环境内的所有进程,然后重新启动 Agent。
30+
31+
**什么能回滚**:文件系统的一切——安装的软件、配置文件、临时文件。
32+
33+
**什么不能回滚**:已经发出去的网络请求、已经过去的时间。发出去的 HTTP 请求收不回来。
34+
35+
### 锦标赛模式(Tournament)
36+
37+
当需要尝试多个方案时,agentenv 可以同时开 N 个独立的环境副本,每个副本跑一个候选方案并执行测试,自动保留第一个通过测试的方案。
38+
39+
类似于同时开 N 个平行世界,选最好的那个。
40+
41+
---
42+
43+
## 架构:三层设计
44+
45+
agentenv 分三层,**Agent 只感知到最里面一层**
46+
47+
```
48+
┌─────────────────────────────────────────────────────┐
49+
│ 第 1 层:启动器(Launcher) │
50+
│ 负责创建沙箱,只在启动时运行一次。 │
51+
│ 实际就是 docker run 或 k8s pod 的启动命令。 │
52+
│ │
53+
│ ┌─────────────────────────────────────────────────┐ │
54+
│ │ 第 2 层:管理者(Supervise) │ │
55+
│ │ 常驻后台进程。负责监控文件变化、自动拍快照、 │ │
56+
│ │ 执行回滚。它自己永远不会被回滚。 │ │
57+
│ │ │ │
58+
│ │ ┌─────────────────────────────────────────────┐ │ │
59+
│ │ │ 第 3 层:内部环境 │ │ │
60+
│ │ │ Agent 实际运行的地方。 │ │ │
61+
│ │ │ Shell、软件包、Agent 进程,都在这里。 │ │ │
62+
│ │ │ 回滚 = 杀掉这里的进程 + 恢复文件系统。 │ │ │
63+
│ │ └─────────────────────────────────────────────┘ │ │
64+
│ └─────────────────────────────────────────────────┘ │
65+
└─────────────────────────────────────────────────────┘
66+
```
67+
68+
为什么要分层?因为"管理回滚的人"不能自己也被回滚,否则就没人来执行恢复操作了。所以管理者(第 2 层)始终待在安全区,只有内部环境(第 3 层)会被回滚。
69+
70+
---
71+
72+
## 两种存储后端
73+
74+
agentenv 支持两种方式来管理快照,启动时自动选择合适的那个:
75+
76+
| | copy 模式(默认) | btrfs 模式 |
77+
|---|---|---|
78+
| **适用场景** | 大多数环境,包括受限的 K8s Pod | 有 root 权限且使用 btrfs 文件系统 |
79+
| **是否需要 root** | 不需要 | 需要 |
80+
| **快照速度** | 通过硬链接共享不变的文件,只复制有变化的部分。如果文件系统支持 reflink(如 XFS、ZFS),速度接近瞬时 | 瞬时(利用 btrfs 的写时复制) |
81+
| **编译方式** | 默认,纯 Go 静态编译 | 需要加 `-tags btrfs`,依赖 cgo |
82+
83+
简单理解:copy 模式就像"复制文件夹但跳过没改的文件",btrfs 模式就像"文件系统帮你管理副本,改了才占空间"。
84+
85+
---
86+
87+
## 磁盘上长什么样
88+
89+
```
90+
/agentfs/ ← 默认根目录
91+
├── nodes/ ← 所有快照存在这里
92+
│ ├── abc123/ ← 快照 1(只读)
93+
│ └── def456/ ← 快照 2(只读)
94+
├── work/current/ ← Agent 当前工作的目录(可读写)
95+
├── meta.json ← 快照之间的关系(谁是谁的父节点)
96+
├── agentenv.sock ← 管理用的通信接口
97+
└── agentenv.lock ← 防止多个进程同时操作
98+
```
99+
100+
---
101+
102+
## 常用命令速查
103+
104+
### 初始化与数据交换
105+
106+
| 命令 | 做什么 |
107+
|------|--------|
108+
| `agentenv init --from <目录>` | 从一个已有目录创建初始环境 |
109+
| `agentenv init --tarball <文件>` | 从压缩包创建初始环境 |
110+
| `agentenv init --sync --from <目录>` | 增量同步:把目录中新增的文件合并进来 |
111+
| `agentenv export` | 把环境导出为 tar 包(可以给 `docker import` 用) |
112+
| `agentenv import <文件>` | 导入一个环境包 |
113+
114+
### 运行与管理
115+
116+
| 命令 | 做什么 |
117+
|------|--------|
118+
| `agentenv supervise -- <命令>` | 启动 Agent 并自动管理快照和回滚 |
119+
| `agentenv daemon` | 启动后台守护进程,通过 socket 接受管理命令 |
120+
| `agentenv mcp` | 启动 MCP 服务器,让 Claude Code 等工具通过 MCP 协议来管理环境 |
121+
| `agentenv exec -- <命令>` | 在环境内执行一次性命令(适合脚本和 CI) |
122+
123+
### 快照与回滚
124+
125+
| 命令 | 做什么 |
126+
|------|--------|
127+
| `agentenv commit -m "说明"` | 手动创建一个快照(通常不需要,系统会自动创建) |
128+
| `agentenv checkout <快照ID>` | 回滚到指定快照 |
129+
| `agentenv tag <名字> <快照ID>` | 给快照取一个好记的名字 |
130+
| `agentenv log` | 查看快照历史 |
131+
| `agentenv status` | 查看当前环境状态 |
132+
| `agentenv diff` | 查看当前环境和某个快照的差异 |
133+
| `agentenv gc` | 清理不再需要的旧快照,释放磁盘空间 |
134+
135+
### 高级功能
136+
137+
| 命令 | 做什么 |
138+
|------|--------|
139+
| `agentenv tournament --test "测试命令" -- "方案1" "方案2"` | 并行尝试多个方案,自动选出通过测试的那个 |
140+
| `agentenv ctl <操作>` | 在 Agent 运行时从外部发送管理命令(不会打断 Agent) |
141+
142+
---
143+
144+
## 回滚流程详解
145+
146+
当执行 `checkout` 回滚时,实际发生的事情:
147+
148+
1. **终止进程** — 杀掉内部环境里所有正在运行的程序
149+
2. **恢复文件系统** — 从目标快照还原 `work/current` 目录
150+
3. **更新指针** — 把 HEAD 指向目标快照,安全写入元数据
151+
4. **重启 Agent** — 如果在 `supervise` 模式下,自动从恢复后的环境重新启动 Agent
152+
153+
整个过程中,管理者进程(supervise)始终在线,不受影响。
154+
155+
---
156+
157+
## 需要注意的限制
158+
159+
- **网络操作不可逆**:已发出的 API 请求、已发送的消息无法撤回,这是物理限制
160+
- **不是安全隔离**:agentenv 提供的是环境隔离,不是安全沙箱。如果要运行不信任的代码,应该再套一层虚拟机
161+
- **Linux 限定**:核心功能依赖 Linux 命名空间,macOS 上只能编辑代码,实际运行需要 Linux 环境
162+
- **受限环境的额外要求**:在某些安全策略较严格的 Linux 环境(如 Ubuntu 22.04+ 的 AppArmor)中,可能需要额外放宽安全策略才能运行

0 commit comments

Comments
 (0)